diff --git a/StarterNG.Tests/Fakes/TestScenery.cs b/StarterNG.Tests/Fakes/TestScenery.cs
new file mode 100644
index 0000000..79a2687
--- /dev/null
+++ b/StarterNG.Tests/Fakes/TestScenery.cs
@@ -0,0 +1,16 @@
+using StarterNG.Application.Abstractions;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Sceneries;
+
+namespace StarterNG.Tests.Fakes;
+
+/// The scenery reader over a fake clock and dice, for tests of what it makes of a text.
+public static class TestScenery
+{
+ /// The hour a scenery that names none starts at.
+ public static SceneryParser Parser(DateTime? now = null) =>
+ new(new FixedClock(now ?? new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
+
+ public static SceneryRepository Repository(IFileSystem files) =>
+ new(files, new GamePaths("game"), TestLog.Over(files), Parser());
+}
diff --git a/StarterNG.Tests/GameLibraryTests.cs b/StarterNG.Tests/GameLibraryTests.cs
index 1efe8a5..d2a2dfd 100644
--- a/StarterNG.Tests/GameLibraryTests.cs
+++ b/StarterNG.Tests/GameLibraryTests.cs
@@ -72,4 +72,45 @@ public class GameLibraryTests
Assert.Contains("textures.txt", installation.Log.Text);
}
+
+ [Fact]
+ public void A_folder_of_the_rolling_stock_that_cannot_be_listed_costs_only_itself()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "textures.txt"), "ep09-1.mat=ep09.e3d,ep09")
+ .WithFile(TestInstallation.At("dynamic", "aaa", "ep09", "ep09.fiz"), "Param: M=2")
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07")
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.fiz"), "Param: M=1")
+ .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
+ .DenyListing(TestInstallation.At("dynamic", "aaa"));
+ var installation = new TestInstallation(files);
+
+ installation.Library.Load();
+
+ Assert.True(installation.Library.Loaded);
+ Assert.Equal("ep07-135", Assert.Single(installation.Library.Vehicles.Textures).Skinfile);
+ Assert.Equal("Katowice", Assert.Single(installation.Library.Sceneries).Name);
+ Assert.Contains("aaa", installation.Log.Text);
+ Assert.Equal(1, installation.Physics.For("pkp/ep07/", "ep07")!.Mass);
+ }
+
+ [Fact]
+ public void The_whole_rolling_stock_failing_still_leaves_the_sceneries()
+ {
+ var installation = Complete();
+ var library = new GameLibrary(new FailingVehicles(), installation.Sceneries, installation.MiniTextures,
+ installation.Physics, installation.Log);
+
+ library.Load();
+
+ Assert.True(library.Loaded);
+ Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name);
+ Assert.Contains("no disk today", installation.Log.Text);
+ }
+
+ private sealed class FailingVehicles : StarterNG.Application.Abstractions.IVehicleRepository
+ {
+ public int Load(StarterNG.Domain.Vehicles.VehicleCatalog catalog) =>
+ throw new IOException("no disk today");
+ }
}
diff --git a/StarterNG.Tests/SceneryExportTests.cs b/StarterNG.Tests/SceneryExportTests.cs
index 4fdbb1c..2bc93e9 100644
--- a/StarterNG.Tests/SceneryExportTests.cs
+++ b/StarterNG.Tests/SceneryExportTests.cs
@@ -1,13 +1,9 @@
-using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryExportTests
{
- private static SceneryParser Parser() =>
- new(new FixedClock(new DateTime(2026, 1, 1, 12, 0, 0)), new StubRandom());
-
[Fact]
public void An_untouched_scenery_exports_its_trainsets_back_verbatim()
{
@@ -18,7 +14,7 @@ public class SceneryExportTests
endtrainset
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -27,6 +23,45 @@ public class SceneryExportTests
Assert.Contains("endtrainset", exported);
}
+ [Fact]
+ public void A_consist_emptied_stays_out_of_the_export()
+ {
+ const string scn = """
+ //$n Test
+ trainset pociag tor_1 10 0
+ node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
+ endtrainset
+ FirstInit
+ """;
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ scenery.Trainsets[0].RemoveVehicles();
+ string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
+
+ Assert.DoesNotContain("trainset pociag", exported);
+ Assert.DoesNotContain("ep07-001", exported);
+ Assert.Contains("FirstInit", exported);
+ }
+
+ [Fact]
+ public void An_entry_that_could_not_be_read_goes_when_emptied_too()
+ {
+ const string scn = """
+ //$n Test
+ trainset pociag tor_1 10 0
+ node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0
+ endtrainset
+ FirstInit
+ """;
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+ scenery.Trainsets[0].Parsed = false;
+
+ scenery.Trainsets[0].RemoveVehicles();
+ string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
+
+ Assert.DoesNotContain("ep07-001", exported);
+ }
+
[Fact]
public void Selected_optional_includes_are_written_back_before_FirstInit()
{
@@ -36,7 +71,7 @@ public class SceneryExportTests
endoptional
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -54,7 +89,7 @@ public class SceneryExportTests
endoptional
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Includes[0].Selected = false;
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
@@ -71,7 +106,7 @@ public class SceneryExportTests
endoptional
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.HasCompanionTimetable = false;
Assert.Contains("include rozklad.inc",
@@ -95,7 +130,7 @@ public class SceneryExportTests
time 8:30 0 0 endtime
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
scenery.Weather.Day = 200;
scenery.Weather.Temperature = 25;
@@ -120,7 +155,7 @@ public class SceneryExportTests
endtrainset
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.True(scenery.Trainsets[0].Decor);
Assert.DoesNotContain("trainset ai_1",
@@ -136,11 +171,28 @@ public class SceneryExportTests
node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
Assert.Contains("wagon_1", exported);
Assert.Contains("enddynamic", exported);
}
+
+ [Theory]
+ [InlineData(0, "3 0 enddynamic")]
+ [InlineData(3, "3 3 pantstate enddynamic")]
+ public void A_load_type_is_written_only_after_a_count_above_zero(int count, string tail)
+ {
+ // After a 0 the simulator would read the type as a destination and take
+ // the next vehicle with it.
+ var car = new StarterNG.Classes.Dynamic
+ {
+ Name = "en57", DataFolder = "pkp/en57", SkinFile = "s", MmdFile = "m",
+ LoadType = StarterNG.Classes.Dynamic.PantState, LoadCount = count
+ };
+ car.Coupling.Flags = 3;
+
+ Assert.EndsWith(tail + "\n", car.ToTrainsetNode());
+ }
}
diff --git a/StarterNG.Tests/SceneryFaultTextTests.cs b/StarterNG.Tests/SceneryFaultTextTests.cs
new file mode 100644
index 0000000..9a94b0a
--- /dev/null
+++ b/StarterNG.Tests/SceneryFaultTextTests.cs
@@ -0,0 +1,77 @@
+using StarterNG.Domain.Sceneries;
+
+namespace StarterNG.Tests;
+
+public class SceneryFaultTextTests
+{
+ private static string Wording(string key) => key switch
+ {
+ "SyntaxLost" => "lost: {0}",
+ "SyntaxUnmade" => "unmade: {0}",
+ "SyntaxMore" => "and {0} more",
+ _ => key + " {0} {1}"
+ };
+
+ private static string Format(int limit, params SceneryFault[] faults) =>
+ SceneryFaultText.Format(faults, limit, Wording);
+
+ [Fact]
+ public void No_faults_read_as_nothing() =>
+ Assert.Equal("", Format(10));
+
+ [Fact]
+ public void The_place_and_the_fault_stand_in_two_columns_as_wide_as_the_longest_place()
+ {
+ string text = Format(10,
+ new SceneryFault(SceneryFaultKind.VehicleIncomplete, "a.scn", 3, "v1", "x"),
+ new SceneryFault(SceneryFaultKind.NotANumber, "long.inc", 120, "v2", "y"));
+
+ Assert.Equal(
+ "a.scn:3 SyntaxVehicleIncomplete v1 x\n" +
+ "long.inc:120 SyntaxNotANumber v2 y",
+ text);
+ }
+
+ [Fact]
+ public void What_a_fault_has_no_subject_or_token_for_is_filled_in()
+ {
+ string text = Format(10, new SceneryFault(SceneryFaultKind.StrayEndTrainset, "a.scn", 3));
+
+ Assert.Equal("a.scn:3 SyntaxStrayEndTrainset ?", text);
+ }
+
+ [Fact]
+ public void What_a_fault_costs_stands_under_it_in_the_second_column()
+ {
+ string text = Format(10,
+ new SceneryFault(SceneryFaultKind.VehicleNotClosed, "a.scn", 3, "v1", "x") { Lost = "v2, v3", Unmade = "b" });
+
+ Assert.Equal(
+ "a.scn:3 SyntaxVehicleNotClosed v1 x\n" +
+ " lost: v2, v3\n" +
+ " unmade: b",
+ text);
+ }
+
+ [Fact]
+ public void Past_the_limit_the_faults_are_only_counted_and_do_not_widen_the_column()
+ {
+ string text = Format(1,
+ new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"),
+ new SceneryFault(SceneryFaultKind.NotANumber, "a-longer-name.scn", 4, "v2", "y"),
+ new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 5, "v3", "z"));
+
+ Assert.Equal(
+ "a.scn:3 SyntaxNotANumber v1 x\n" +
+ "and 2 more",
+ text);
+ }
+
+ [Fact]
+ public void Exactly_as_many_as_the_limit_are_not_counted_as_more()
+ {
+ string text = Format(1, new SceneryFault(SceneryFaultKind.NotANumber, "a.scn", 3, "v1", "x"));
+
+ Assert.DoesNotContain("more", text);
+ }
+}
diff --git a/StarterNG.Tests/SceneryFileWatchTests.cs b/StarterNG.Tests/SceneryFileWatchTests.cs
new file mode 100644
index 0000000..d33bd3f
--- /dev/null
+++ b/StarterNG.Tests/SceneryFileWatchTests.cs
@@ -0,0 +1,376 @@
+using StarterNG.Application;
+using StarterNG.Domain.Sceneries;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class SceneryFileWatchTests
+{
+ private static readonly string Scn = TestInstallation.At("scenery", "kato.scn");
+ private static readonly string Other = TestInstallation.At("scenery", "lk4.scn");
+ private static readonly string Inc = TestInstallation.At("scenery", "auta.inc");
+
+ private const string Text =
+ "//$n Katowice\n" +
+ "FirstInit\n" +
+ "trainset a tor 0 0\n" +
+ "node -1 0 v1 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
+ "endtrainset\n" +
+ "trainset b tor 0 0\n" +
+ "node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" +
+ "endtrainset\n";
+
+ private static (TestInstallation Installation, SceneryFileWatch Watch, AppState State) Rig(InMemoryFileSystem files)
+ {
+ var installation = new TestInstallation(files);
+ installation.Library.ReloadSceneries();
+ var state = new AppState();
+ return (installation, new SceneryFileWatch(files, installation.Sceneries, installation.Library, state), state);
+ }
+
+ private static InMemoryFileSystem Folder() => new InMemoryFileSystem().WithFile(Scn, Text);
+
+ [Fact]
+ public void Files_as_they_were_read_are_no_change()
+ {
+ var (installation, watch, _) = Rig(Folder());
+
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+ }
+
+ [Fact]
+ public void A_file_changed_outside_is_read_anew()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+
+ files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
+
+ var change = Assert.Single(watch.Look(installation.Library.Sceneries));
+ Assert.Same(current, change.Current);
+ Assert.Equal("Katowice Główne", change.Fresh!.Name);
+ }
+
+ [Fact]
+ public void An_include_that_changed_or_came_makes_the_scenery_read_anew()
+ {
+ var files = new InMemoryFileSystem().WithFile(Scn, Text + "include auta.inc end\n");
+ var (installation, watch, _) = Rig(files);
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+
+ files.WriteAllText(Inc, "// auta\n");
+ Assert.Single(watch.Look(installation.Library.Sceneries));
+
+ installation.Library.ReloadSceneries();
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+
+ files.WriteAllText(Inc, "// inne auta\n");
+ Assert.Single(watch.Look(installation.Library.Sceneries));
+ }
+
+ [Fact]
+ public void A_new_file_in_the_folder_comes_as_an_added_scenery()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+
+ files.WriteAllText(Other, "//$n Lublin\n");
+
+ var change = Assert.Single(watch.Look(installation.Library.Sceneries));
+ Assert.Null(change.Current);
+ Assert.Equal("Lublin", change.Fresh!.Name);
+ }
+
+ [Fact]
+ public void A_file_is_gone_only_if_still_away_on_the_next_look()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+
+ files.DeleteFile(Scn);
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+
+ var change = Assert.Single(watch.Look(installation.Library.Sceneries));
+ Assert.Same(current, change.Current);
+ Assert.Null(change.Fresh);
+ }
+
+ [Fact]
+ public void A_file_replaced_between_two_looks_is_not_gone()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+
+ files.DeleteFile(Scn);
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+
+ files.WriteAllText(Scn, Text);
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+ }
+
+ [Fact]
+ public void A_file_that_will_not_load_is_tried_and_logged_once_until_it_can_be()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ files.WriteAllText(Other, "//$n Lublin\n");
+ files.FailReading(Other);
+
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+ string once = installation.Log.Text;
+ Assert.Contains("lk4.scn", once);
+
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+ Assert.Equal(once, installation.Log.Text);
+
+ files.AllowReading(Other);
+ Assert.Equal("Lublin", Assert.Single(watch.Look(installation.Library.Sceneries)).Fresh!.Name);
+ }
+
+ [Fact]
+ public void Its_own_save_is_no_change_from_outside()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var scenery = installation.Library.Sceneries.Single();
+ var trainset = scenery.Trainsets[0];
+ trainset.ReadyToGo = true;
+
+ var log = new FileDiagnosticsLog(files, installation.Clock, installation.Paths);
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, trainset).Outcome);
+
+ Assert.False(watch.IsStale(scenery));
+ Assert.Empty(watch.Look(installation.Library.Sceneries));
+ }
+
+ [Fact]
+ public void A_consist_whose_text_stands_goes_over_with_its_edits()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+ var edited = current.Trainsets[1];
+ edited.ReadyToGo = true;
+
+ files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
+ var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
+
+ Assert.Empty(SceneryFileWatch.CarryOver(current, fresh));
+ Assert.Same(edited, fresh.Trainsets[1]);
+ Assert.True(SceneryTrainsetText.IsModified(fresh.Trainsets[1]));
+ }
+
+ [Fact]
+ public void An_edited_consist_whose_text_changed_too_is_the_conflict()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+ var edited = current.Trainsets[1];
+ edited.ReadyToGo = true;
+
+ files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
+ var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
+
+ Assert.Same(edited, Assert.Single(SceneryFileWatch.CarryOver(current, fresh)));
+ Assert.NotSame(edited, fresh.Trainsets[1]);
+ }
+
+ [Fact]
+ public void A_consist_changed_but_not_edited_just_gives_way()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+
+ files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
+ var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
+
+ Assert.Empty(SceneryFileWatch.CarryOver(current, fresh));
+ Assert.Equal("v22", fresh.Trainsets[1].Vehicles.Single().Name);
+ }
+
+ [Fact]
+ public void The_weather_chosen_by_hand_stays_and_the_authored_one_follows_the_file()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+ current.Weather.Day = 123;
+
+ files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
+ var fresh = watch.Look(installation.Library.Sceneries).Single().Fresh!;
+ SceneryFileWatch.CarryOver(current, fresh);
+
+ Assert.Equal(123, fresh.Weather.Day);
+ }
+
+ [Fact]
+ public void What_is_picked_follows_into_the_scenery_read_anew()
+ {
+ var files = Folder();
+ var (installation, watch, state) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+ var picked = current.Trainsets[1];
+ state.CurrentScenery = current;
+ state.CurrentTrainset = picked;
+ state.StartingVehicleName = "v2";
+
+ files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
+ var change = watch.Look(installation.Library.Sceneries).Single();
+ SceneryFileWatch.CarryOver(change.Current!, change.Fresh!);
+ watch.Apply(change);
+
+ Assert.Same(change.Fresh, installation.Library.Sceneries.Single());
+ Assert.Same(change.Fresh, state.CurrentScenery);
+ Assert.Same(picked, state.CurrentTrainset);
+ Assert.Equal("v2", state.StartingVehicleName);
+ }
+
+ [Fact]
+ public void A_picked_consist_changed_in_the_file_is_followed_by_its_name()
+ {
+ var files = Folder();
+ var (installation, watch, state) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+ var picked = current.Trainsets[1];
+ state.CurrentScenery = current;
+ state.CurrentTrainset = picked;
+ state.StartingVehicleName = "v2";
+
+ files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
+ var change = watch.Look(installation.Library.Sceneries).Single();
+ SceneryFileWatch.CarryOver(change.Current!, change.Fresh!);
+ watch.Apply(change);
+
+ Assert.Same(change.Fresh!.Trainsets[1], state.CurrentTrainset);
+ Assert.Null(state.StartingVehicleName);
+ }
+
+ [Fact]
+ public void A_change_to_a_scenery_reloaded_since_the_look_is_left()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+
+ files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
+ var change = watch.Look(installation.Library.Sceneries).Single();
+ installation.Library.ReloadSceneries();
+
+ Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
+ }
+
+ [Fact]
+ public void A_change_to_a_scenery_saved_from_here_since_the_look_is_left_and_nothing_carried_over()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+
+ files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
+ var change = watch.Look(installation.Library.Sceneries).Single();
+ current.Sources = change.Fresh!.Sources;
+
+ Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
+ Assert.NotSame(current.Trainsets[0], change.Fresh.Trainsets[0]);
+ }
+
+ [Fact]
+ public void A_scenery_new_in_the_folder_is_taken()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+
+ files.WriteAllText(Other, "//$n Lublin\n");
+ var change = watch.Look(installation.Library.Sceneries).Single();
+
+ Assert.Equal(SceneryVerdict.Take, watch.Judge(change).Verdict);
+ }
+
+ [Fact]
+ public void A_scenery_new_in_the_folder_that_got_in_since_the_look_is_left()
+ {
+ var (installation, watch, _) = Rig(Folder());
+
+ var change = new SceneryChange(null, installation.Library.Sceneries.Single());
+
+ Assert.Equal(SceneryVerdict.Leave, watch.Judge(change).Verdict);
+ }
+
+ [Fact]
+ public void A_change_that_loses_no_edits_is_taken_with_the_edits_carried_over()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var edited = installation.Library.Sceneries.Single().Trainsets[1];
+ edited.ReadyToGo = true;
+
+ files.WriteAllText(Scn, Text.Replace("Katowice", "Katowice Główne"));
+ var change = watch.Look(installation.Library.Sceneries).Single();
+
+ var judgement = watch.Judge(change);
+
+ Assert.Equal(SceneryVerdict.Take, judgement.Verdict);
+ Assert.Empty(judgement.Lost);
+ Assert.Same(edited, change.Fresh!.Trainsets[1]);
+ }
+
+ [Fact]
+ public void A_change_that_loses_edits_is_asked_about_naming_them()
+ {
+ var files = Folder();
+ var (installation, watch, _) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+ var edited = current.Trainsets[1];
+ edited.ReadyToGo = true;
+
+ files.WriteAllText(Scn, Text.Replace(" v2 ", " v22 "));
+ var change = watch.Look(installation.Library.Sceneries).Single();
+
+ var judgement = watch.Judge(change);
+
+ Assert.Equal(SceneryVerdict.AskFirst, judgement.Verdict);
+ Assert.Same(edited, Assert.Single(judgement.Lost));
+ Assert.Same(current.Trainsets[0], change.Fresh!.Trainsets[0]);
+ }
+
+ [Fact]
+ public void A_scenery_gone_with_edits_in_it_stays_for_them_to_be_saved_elsewhere()
+ {
+ var (installation, watch, _) = Rig(Folder());
+ var current = installation.Library.Sceneries.Single();
+ current.Trainsets[1].ReadyToGo = true;
+
+ Assert.Equal(SceneryVerdict.Leave, watch.Judge(new SceneryChange(current, null)).Verdict);
+ }
+
+ [Fact]
+ public void A_scenery_gone_with_no_edits_in_it_is_taken_off()
+ {
+ var (installation, watch, _) = Rig(Folder());
+ var current = installation.Library.Sceneries.Single();
+
+ Assert.Equal(SceneryVerdict.Take, watch.Judge(new SceneryChange(current, null)).Verdict);
+ }
+
+ [Fact]
+ public void A_scenery_gone_takes_what_was_picked_with_it()
+ {
+ var files = Folder();
+ var (installation, watch, state) = Rig(files);
+ var current = installation.Library.Sceneries.Single();
+ state.CurrentScenery = current;
+ state.CurrentTrainset = current.Trainsets[0];
+ state.StartingVehicleName = "v1";
+
+ watch.Apply(new SceneryChange(current, null));
+
+ Assert.Empty(installation.Library.Sceneries);
+ Assert.Null(state.CurrentScenery);
+ Assert.Null(state.CurrentTrainset);
+ Assert.Null(state.StartingVehicleName);
+ }
+}
diff --git a/StarterNG.Tests/SceneryParserTests.cs b/StarterNG.Tests/SceneryParserTests.cs
index 5a5dd84..75cd996 100644
--- a/StarterNG.Tests/SceneryParserTests.cs
+++ b/StarterNG.Tests/SceneryParserTests.cs
@@ -1,13 +1,10 @@
-using StarterNG.Infrastructure.Sceneries;
+using StarterNG.Domain.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryParserTests
{
- private static SceneryParser Parser(DateTime? now = null) =>
- new(new FixedClock(now ?? new DateTime(2026, 5, 4, 9, 15, 0)), new StubRandom());
-
[Fact]
public void Directives_become_the_scenery_headline()
{
@@ -19,7 +16,7 @@ public class SceneryParserTests
//$i images/kato.jpg
""";
- var scenery = Parser().Parse("scenery/kato.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/kato.scn", scn);
Assert.Equal("Śląsk", scenery.Group);
Assert.Equal("Osobowy do Katowic", scenery.Name);
@@ -32,7 +29,7 @@ public class SceneryParserTests
[Fact]
public void The_a_directive_marks_a_scenery_archival()
{
- var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
+ var scenery = TestScenery.Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
Assert.True(scenery.Archival);
}
@@ -46,7 +43,7 @@ public class SceneryParserTests
//$f doc/notes.txt
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Collection(scenery.Attachments,
first =>
@@ -76,7 +73,7 @@ public class SceneryParserTests
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var include = Assert.Single(scenery.Includes);
Assert.Equal("zima.inc", include.FilePath);
@@ -95,7 +92,7 @@ public class SceneryParserTests
endoptional
""";
- var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes);
+ var include = Assert.Single(TestScenery.Parser().Parse("scenery/x.scn", scn).Includes);
Assert.Equal(2, include.Kind);
Assert.False(include.Selected);
@@ -112,7 +109,7 @@ public class SceneryParserTests
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var trainset = Assert.Single(scenery.Trainsets);
Assert.True(trainset.Parsed);
@@ -126,11 +123,11 @@ public class SceneryParserTests
public void Vehicles_outside_a_trainset_are_collected_as_loose()
{
const string scn = """
- node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic
+ node -1 -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic
FirstInit
""";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
var vehicle = Assert.Single(scenery.LooseVehicles);
Assert.Equal("wagon_1", vehicle.Name);
@@ -142,13 +139,43 @@ public class SceneryParserTests
[Fact]
public void A_malformed_loose_vehicle_is_reported_and_left_in_place()
{
- const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 enddynamic\n";
+ const string scn = "node x -1 wagon_1 dynamic PKP/111A 111a 111a.mmd none 0 nobody 0 0 enddynamic\n";
- var scenery = Parser().Parse("scenery/x.scn", scn);
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
Assert.Empty(scenery.LooseVehicles);
Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
- Assert.Contains(scenery.Faults, fault => fault.Contains("wagon_1"));
+ var fault = Assert.Single(scenery.Faults);
+ Assert.Equal((SceneryFaultKind.NotANumber, "wagon_1", "x", 1), (fault.Kind, fault.Subject, fault.Token, fault.Line));
Assert.Contains("enddynamic", scenery.Template);
}
+
+ [Fact]
+ public void Directives_in_a_file_put_in_place_are_not_the_scenery_s_own()
+ {
+ const string included = """
+ //$n Auta
+ //$l Inne
+ //$d Opis aut
+ //$f doc/auta.pdf Auta
+ //$i auta.jpg
+ //$a
+ trainset auto road1 0 50
+ endtrainset
+ """;
+ string text = SceneryIncludes.Inline("//$d Opis scenerii\n//$f doc/mapa.png Mapa\nFirstInit\ninclude auta.scm end\n",
+ name => (name, included));
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", text);
+
+ Assert.Equal("Opis scenerii", scenery.Description);
+ Assert.Equal("doc/mapa.png", Assert.Single(scenery.Attachments).FilePath);
+ Assert.Null(scenery.Name);
+ Assert.Null(scenery.Group);
+ Assert.Null(scenery.ImageName);
+ Assert.False(scenery.Archival);
+ // The file's own trainset is still listed, and its text still written for a start.
+ Assert.Equal("auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
+ Assert.Contains("//$d Opis aut", scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
+ }
}
diff --git a/StarterNG.Tests/ScenerySyntaxTests.cs b/StarterNG.Tests/ScenerySyntaxTests.cs
new file mode 100644
index 0000000..3052cd3
--- /dev/null
+++ b/StarterNG.Tests/ScenerySyntaxTests.cs
@@ -0,0 +1,382 @@
+using StarterNG.Domain.Sceneries;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class ScenerySyntaxTests
+{
+ private static (SceneryFaultKind, string, int, string?, string?) Seen(SceneryFault fault) =>
+ (fault.Kind, fault.File, fault.Line, fault.Subject, fault.Token);
+
+ [Fact]
+ public void A_trainset_commented_out_is_no_trainset()
+ {
+ const string scn = """
+ /* trainset stary tor_1 0 0
+ node -1 0 st dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ endtrainset */
+ // trainset dla gracza:
+ trainset pociag tor_1 10 0
+ node -1 0 ep07-001 dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic//koniec
+ endtrainset
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ var trainset = Assert.Single(scenery.Trainsets);
+ Assert.Equal("pociag", trainset.Name);
+ Assert.Single(trainset.Vehicles);
+ Assert.Empty(scenery.Faults);
+ Assert.Contains("trainset stary", scenery.Template);
+ }
+
+ [Fact]
+ public void A_trainset_without_its_end_is_reported_and_the_next_one_read_on_its_own()
+ {
+ const string scn = """
+ FirstInit
+ trainset pierwszy tor_1 10 0
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ trainset drugi tor_2 10 0
+ node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ endtrainset
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ Assert.Equal((SceneryFaultKind.TrainsetNotClosed, "x.scn", 2, "pierwszy", null),
+ Seen(Assert.Single(scenery.Faults)));
+ var trainset = Assert.Single(scenery.Trainsets);
+ Assert.Equal("drugi", trainset.Name);
+ Assert.Equal("b", Assert.Single(trainset.Vehicles).Name);
+ // The unended one is the simulator's to read as written.
+ Assert.Contains("trainset pierwszy", scenery.Template);
+ }
+
+ [Fact]
+ public void A_vehicle_without_its_end_is_reported_yet_its_trainset_listed()
+ {
+ const string scn = """
+ trainset pociag tor_1 10 0
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3
+ node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
+ endtrainset
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ var fault = Assert.Single(scenery.Faults);
+ Assert.Equal((SceneryFaultKind.VehicleNotClosed, "x.scn", 2, "a", null), Seen(fault));
+ // The simulator goes on to the next enddynamic, taking b for a's.
+ Assert.Equal("b", fault.Lost);
+ Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name));
+ }
+
+ [Fact]
+ public void A_vehicle_with_no_load_count_takes_the_next_one_with_it()
+ {
+ const string scn = """
+ trainset pociag tor_1 10 0
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 enddynamic
+ node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
+ endtrainset
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ // Its enddynamic read for the count, "node" for a destination, b's for its end.
+ var fault = Assert.Single(scenery.Faults);
+ Assert.Equal((SceneryFaultKind.LoadCountMissing, 2, "a", "b"), (fault.Kind, fault.Line, fault.Subject, fault.Lost));
+ Assert.Equal(new[] { "a", "b" }, Assert.Single(scenery.Trainsets).Vehicles.Select(v => v.Name));
+ }
+
+ [Fact]
+ public void A_load_with_no_type_runs_on_into_the_next_trainset()
+ {
+ const string scn = """
+ trainset pierwszy tor_1 10 0
+ node -1 0 a dynamic PKP/412Z 412z 412z 0 nobody 3 40 enddynamic
+ endtrainset
+ trainset drugi tor_2 10 0
+ node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ node -1 0 c dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
+ endtrainset
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ // The second endtrainset is what ends the first trainset then.
+ var fault = Assert.Single(scenery.Faults);
+ Assert.Equal((SceneryFaultKind.LoadTypeMissing, 2, "a", "40", "endtrainset, trainset drugi, b"),
+ (fault.Kind, fault.Line, fault.Subject, fault.Token, fault.Lost));
+ Assert.Equal(new[] { "pierwszy", "drugi" }, scenery.Trainsets.Select(t => t.Name));
+ }
+
+ [Theory]
+ [InlineData("0 none", "none")]
+ [InlineData("0 pantstate", "pantstate")]
+ [InlineData("0 cel Katowice", null)]
+ [InlineData("3 pantstate", null)]
+ [InlineData("12 wegiel", null)]
+ public void One_word_after_the_load_is_taken_for_a_destination_named_enddynamic(string load, string? word)
+ {
+ string scn = $"""
+ trainset pociag tor_1 10 0
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 {load} enddynamic
+ node -1 0 b dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
+ endtrainset
+ """;
+
+ var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
+
+ if (word is null)
+ Assert.Empty(faults);
+ else
+ Assert.Equal((SceneryFaultKind.WordBeforeEnd, word, "b"), (faults.Single().Kind, faults.Single().Token, faults.Single().Lost));
+ }
+
+ [Fact]
+ public void A_header_written_twice_is_told_as_such()
+ {
+ const string scn = """
+ trainset baltyk\mod\exe45151 chojnice301 40 0.1
+ //$o -Skład ozdobny
+ trainset baltyk\mod\exe45151 chojnice301 40 0.1
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ endtrainset
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ Assert.Equal((SceneryFaultKind.TrainsetHeaderRepeated, "x.scn", 1, "baltyk\\mod\\exe45151", null),
+ Seen(Assert.Single(scenery.Faults)));
+ Assert.Single(scenery.Trainsets);
+ }
+
+ [Fact]
+ public void A_trainset_whose_header_is_swallowed_is_told_at_the_fault_that_began_it()
+ {
+ const string scn = """
+ trainset pierwszy tor_1 10 0
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ endtrainset
+ //trainset bez_naglowka tor_2 0 0
+ node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ endtrainset
+ trainset trzeci tor_3 0 0
+ node -1 0 c1 dynamic PKP/EU07 eu07 eu07 0 headdriver 3 0 enddynamic
+ node -1 0 c2 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
+ node -1 0 c3 dynamic PKP/111A 111a 111a 0 nobody 3 0 enddynamic
+ endtrainset
+ trainset czwarty tor_4 0 0
+ node -1 0 d dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ endtrainset
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ // c2 with no trainset takes c3, and the endtrainset is left alone - all of
+ // it from b, where the fault is.
+ var fault = Assert.Single(scenery.Faults);
+ Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 5, "endtrainset, trainset trzeci, c1", "trzeci"),
+ (fault.Kind, fault.Line, fault.Lost, fault.Unmade));
+ Assert.Equal(new[] { "pierwszy", "trzeci", "czwarty" }, scenery.Trainsets.Select(t => t.Name));
+ }
+
+ [Fact]
+ public void Text_where_a_number_belongs_is_named()
+ {
+ const string scn = """
+ trainset pociag tor_1 1O 0
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0,5 headdriver 3 0 enddynamic
+ endtrainset
+ """;
+
+ var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
+
+ Assert.Equal(new[]
+ {
+ (SceneryFaultKind.NotANumber, "x.scn", 1, (string?)"pociag", (string?)"1O"),
+ (SceneryFaultKind.NotANumber, "x.scn", 2, "a", "0,5")
+ }, faults.Select(Seen));
+ }
+
+ [Fact]
+ public void A_track_name_split_by_a_space_is_named_whole()
+ {
+ // "Pawonków#L" with its "ó" lost to a space.
+ const string scn = """
+ trainset none Pawonk w#L 200.0 0.0
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ endtrainset
+ """;
+
+ var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults;
+
+ Assert.Equal((SceneryFaultKind.TrackNameSplit, "x.scn", 1, "none", "Pawonk w#L"), Seen(Assert.Single(faults)));
+ }
+
+ [Fact]
+ public void Vehicles_left_without_their_trainset_header_are_told_so()
+ {
+ const string scn = """
+ //trainset 2003r\64040 Staniszcze_Male_po#2 -50 0.1
+ node -1 0 EN57-1542ra dynamic pkp\en57_v2 EN57-1542ra EN57RA 0 headdriver 255 0 enddynamic
+ endtrainset
+ node -1 0 auto dynamic road\ford transit transit droga-b 0 headdriver 25.0 0 enddynamic
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ // On no track the simulator has, it drops the vehicle and goes on to the
+ // next enddynamic - past the endtrainset, to the car's.
+ var fault = Assert.Single(scenery.Faults);
+ Assert.Equal((SceneryFaultKind.VehicleOutsideTrainset, 2, "endtrainset, auto"), (fault.Kind, fault.Line, fault.Lost));
+ Assert.Equal("auto", Assert.Single(scenery.LooseVehicles).Name);
+ }
+
+ [Fact]
+ public void Structure_faults_are_found_where_the_simulator_reports_them()
+ {
+ const string scn = """
+ endtrainset
+ trainset pusty tor_1 10 0
+ endtrainset trainset pociag tor_1 10 0
+ node -1 0 a dynamic PKP/EP07 ep07 ep07 0 headdriver enddynamic
+ node -1 0 b dynamic PKP/EP07 ep07 ep07 0 headdriver 3 0 enddynamic
+ reversed
+ endtrainset
+ include dekoracje.scm // bez end
+ include auta.scm end
+ /* bez końca
+ """;
+
+ var faults = TestScenery.Parser().Parse("scenery/x.scn", scn).Faults.Select(f => (f.Kind, f.Line)).ToList();
+
+ Assert.Equal(new[]
+ {
+ (SceneryFaultKind.StrayEndTrainset, 1),
+ (SceneryFaultKind.VehicleIncomplete, 4),
+ (SceneryFaultKind.ReversedMisplaced, 6),
+ (SceneryFaultKind.IncludeNotClosed, 8),
+ (SceneryFaultKind.UnclosedComment, 10)
+ }, faults);
+ }
+
+ [Fact]
+ public void A_trainset_with_no_vehicles_is_listed_to_be_filled_in_the_depot()
+ {
+ const string scn = """
+ trainset td/rozklad tdo_n1734 0.0 0.1
+ //$o Skład gotowy na żeberku.
+ endtrainset
+ include koniec.scm
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn, _ => new IncludeLookup(true));
+
+ Assert.Empty(scenery.Faults);
+ Assert.Equal("td/rozklad", Assert.Single(scenery.Trainsets).Name);
+ }
+
+ [Fact]
+ public void Reversed_may_follow_the_assignments_ahead_of_the_vehicles()
+ {
+ const string scn = """
+ Trainset pociag tor_1 10 0
+ assignment pl "Pociąg" endassignment
+ reversed
+ Node -1 0 a dynamic PKP/EP07 ep07 ep07 0 HeadDriver 3 0 EndDynamic
+ EndTrainset
+ """;
+
+ var scenery = TestScenery.Parser().Parse("scenery/x.scn", scn);
+
+ Assert.Empty(scenery.Faults);
+ var trainset = Assert.Single(scenery.Trainsets);
+ Assert.True(trainset.Reversed);
+ Assert.Equal(StarterNG.Classes.eDriverType.Headdriver, Assert.Single(trainset.Vehicles).DriverType);
+ }
+
+ [Fact]
+ public void A_fault_in_an_included_file_is_placed_in_that_file()
+ {
+ string text = SceneryIncludes.Inline(
+ "FirstInit\ninclude tomaszewo/auta.scm end\ntrainset zly tor 0\nendtrainset\n",
+ name => (name, "// auta\ntrainset auto road1 0 50\nnode -1 0 a1 dynamic car car car x headdriver 3 0 enddynamic\nendtrainset"));
+
+ var faults = TestScenery.Parser().Parse("scenery/x.scn", text).Faults.Select(Seen);
+
+ Assert.Equal(new[]
+ {
+ (SceneryFaultKind.NotANumber, "tomaszewo/auta.scm", 3, (string?)"a1", (string?)"x"),
+ (SceneryFaultKind.TrainsetIncomplete, "x.scn", 3, "zly", null)
+ }, faults);
+ }
+
+ [Fact]
+ public void Missing_includes_are_reported_but_optional_ones_and_templates_are_not()
+ {
+ const string scn = """
+ include tory.scm end
+ include brak.scm end // komentarz
+ include budynek.inc (p1) 0 0 end
+ include $(p1)_dom.inc end
+ include opcja.scm end //$optional 2,Opcja
+ endoptional
+ """;
+
+ var faults = TestScenery.Parser().Parse("scenery/x.scn", scn, name => new IncludeLookup(name is "tory.scm" or "budynek.inc")).Faults;
+
+ Assert.Equal((SceneryFaultKind.MissingInclude, "x.scn", 2, null, "brak.scm"), Seen(Assert.Single(faults)));
+ }
+
+ [Fact]
+ public void An_include_named_in_another_case_is_found_as_the_simulator_finds_it()
+ {
+ var files = new InMemoryFileSystem(caseSensitive: true)
+ .WithLegacyFile("game/scenery/x.scn", "FirstInit\ninclude Tomaszewo/Auta.SCM end\n")
+ .WithLegacyFile("game/scenery/tomaszewo/auta.scm",
+ "trainset auto road1 0 50\nnode -1 0 a1 dynamic car car car 0 headdriver 3 0 enddynamic\nendtrainset\n");
+ var repository = TestScenery.Repository(files);
+
+ var scenery = repository.Load("game/scenery/x.scn")!;
+
+ Assert.Empty(scenery.Faults);
+ Assert.Equal("tomaszewo/auta.scm", Assert.Single(scenery.Trainsets).SourceFile);
+ }
+
+ [Fact]
+ public void An_include_on_disk_only_in_other_letter_case_is_what_linux_misses()
+ {
+ // The simulator folds the name to lower case and opens it so.
+ var files = new InMemoryFileSystem(caseSensitive: true)
+ .WithLegacyFile("game/scenery/braniewo_szeroki.scn",
+ "include braniewo/base.ctr end\ninclude Braniewo/Tory.SCM end\n")
+ .WithFile("game/scenery/Braniewo/Base.ctr", "")
+ .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)));
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void A_missing_terrain_file_is_no_fault_while_the_scenery_has_its_own_sbt(bool sbt)
+ {
+ 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");
+ var repository = TestScenery.Repository(files);
+
+ var scenery = repository.Load("game/scenery/Lk2_Sandbox.scn")!;
+
+ Assert.Equal(sbt, scenery.HasCompanionTimetable);
+ Assert.Equal(sbt ? 0 : 1, scenery.Faults.Count);
+ }
+}
diff --git a/StarterNG.Tests/SceneryTrainsetSaveTests.cs b/StarterNG.Tests/SceneryTrainsetSaveTests.cs
new file mode 100644
index 0000000..1d2aedc
--- /dev/null
+++ b/StarterNG.Tests/SceneryTrainsetSaveTests.cs
@@ -0,0 +1,578 @@
+using System.Reflection;
+using System.Text;
+using StarterNG.Application;
+using StarterNG.Application.Abstractions;
+using StarterNG.Classes;
+using StarterNG.Domain.Sceneries;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class SceneryTrainsetSaveTests
+{
+ private const string Path = "scenery/test.scn";
+
+ private const string Scn =
+ "//$n Bałtyk\r\n" +
+ "FirstInit\r\n" +
+ "trainset pociag tor_1 200.0 0.0\r\n" +
+ "//$o -Opis składu\r\n" +
+ "node -1 0 ep07-001 dynamic PKP\\EP07 ep07-001 ep07 0 headdriver 3 0 enddynamic\r\n" +
+ "node -1 0 wagon-1 dynamic PKP\\111A b10-1 111a 0 nobody 3 0 enddynamic\r\n" +
+ "endtrainset\r\n" +
+ "// koniec pliku z polską literą: żółw\r\n";
+
+ private static Encoding Cp1250 => Encoding.GetEncoding(1250);
+
+ private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(string scn) =>
+ Setup(Cp1250.GetBytes(scn));
+
+ private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(byte[] bytes)
+ {
+ var files = new InMemoryFileSystem();
+ files.WriteAllBytes(Path, bytes);
+ var scenery = TestScenery.Parser().Parse(Path, LegacyText.Decode(bytes));
+ return (files, scenery, new SaveSceneryTrainset(files, TestLog.Over(files)));
+ }
+
+ [Fact]
+ public void Reversed_after_the_header_is_read_and_written_back()
+ {
+ var trainset = new Trainset(
+ "trainset a tor 0 0 reversed\n" +
+ "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" +
+ "endtrainset");
+
+ Assert.True(trainset.Reversed);
+ Assert.Single(trainset.Vehicles);
+ Assert.StartsWith("trainset a tor 0 0 reversed\n", trainset.GetTrainsetEntry());
+ }
+
+ [Fact]
+ public void A_trainset_without_reversed_is_not_turned()
+ {
+ var trainset = new Trainset("trainset a tor 0 0\nendtrainset");
+
+ Assert.False(trainset.Reversed);
+ Assert.DoesNotContain("reversed", trainset.GetTrainsetEntry());
+ }
+
+ [Fact]
+ public void An_untouched_trainset_is_not_modified_however_its_numbers_are_spelled()
+ {
+ var (_, scenery, _) = Setup(Scn);
+
+ Assert.False(SceneryTrainsetText.IsModified(scenery.Trainsets[0]));
+ }
+
+ [Fact]
+ public void The_head_stays_as_written_and_the_vehicles_are_rewritten()
+ {
+ var (_, scenery, _) = Setup(Scn.Replace("200.0 0.0\r\n", "200.0 0.0\r\nreversed\r\n"));
+ var trainset = scenery.Trainsets[0];
+ trainset.Vehicles.RemoveAt(1);
+
+ string entry = SceneryTrainsetText.Compose(trainset, "\r\n");
+
+ Assert.StartsWith("trainset pociag tor_1 200.0 0.0\r\nreversed\r\n//$o -Opis składu\r\nnode", entry);
+ Assert.Contains("ep07-001", entry);
+ Assert.DoesNotContain("wagon-1", entry);
+ Assert.EndsWith("enddynamic\r\nendtrainset", entry);
+ Assert.DoesNotContain("\r\r", entry);
+ Assert.DoesNotMatch("[^\r]\n", entry);
+ }
+
+ [Fact]
+ public void Only_the_speed_of_the_header_follows_the_ready_switch()
+ {
+ var (_, scenery, _) = Setup(Scn);
+ var trainset = scenery.Trainsets[0];
+ trainset.ReadyToGo = true;
+
+ string entry = SceneryTrainsetText.Compose(trainset, "\n");
+
+ Assert.StartsWith("trainset pociag tor_1 200.0 0.1\n", entry);
+ Assert.True(SceneryTrainsetText.IsModified(trainset));
+ }
+
+ [Fact]
+ public void Saving_rewrites_the_trainset_alone_and_keeps_the_original_once()
+ {
+ var (files, scenery, save) = Setup(Scn);
+ var trainset = scenery.Trainsets[0];
+ trainset.Vehicles.RemoveAt(1);
+
+ var (outcome, _) = save.Execute(scenery, trainset);
+
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, outcome);
+ string written = Cp1250.GetString(files.ReadAllBytes(Path));
+ Assert.StartsWith("//$n Bałtyk\r\nFirstInit\r\ntrainset pociag tor_1 200.0 0.0\r\n//$o -Opis składu\r\n", written);
+ Assert.DoesNotContain("wagon-1", written);
+ Assert.EndsWith("endtrainset\r\n// koniec pliku z polską literą: żółw\r\n", written);
+ Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path + SaveSceneryTrainset.BackupSuffix)));
+ Assert.False(SceneryTrainsetText.IsModified(trainset));
+
+ // A second save finds what the first one wrote; the backup stays the original.
+ trainset.Vehicles.Clear();
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, trainset).Outcome);
+ Assert.DoesNotContain("ep07-001", Cp1250.GetString(files.ReadAllBytes(Path)));
+ Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path + SaveSceneryTrainset.BackupSuffix)));
+ }
+
+ [Fact]
+ public void Of_two_trainsets_written_alike_the_right_one_is_saved()
+ {
+ const string block =
+ "trainset none tor_1 0 0\r\n" +
+ "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\r\n" +
+ "endtrainset\r\n";
+ var (files, scenery, save) = Setup(block + "// x\r\n" + block);
+ var second = scenery.Trainsets[1];
+ second.Vehicles.Clear();
+
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, second).Outcome);
+
+ string written = Cp1250.GetString(files.ReadAllBytes(Path));
+ Assert.StartsWith(block + "// x\r\ntrainset none tor_1 0 0\r\nendtrainset", written);
+ }
+
+ [Fact]
+ public void A_file_changed_meanwhile_is_left_alone()
+ {
+ var (files, scenery, save) = Setup(Scn);
+ var trainset = scenery.Trainsets[0];
+ trainset.Vehicles.Clear();
+ files.WithLegacyFile(Path, Scn.Replace("wagon-1", "wagon-2"));
+
+ Assert.Equal(SceneryTrainsetSaveOutcome.NotFound, save.Execute(scenery, trainset).Outcome);
+ Assert.Contains("wagon-2", Cp1250.GetString(files.ReadAllBytes(Path)));
+ Assert.False(files.FileExists(Path + SaveSceneryTrainset.BackupSuffix));
+ }
+
+ private const string Cars =
+ "trainset none road1 -20 50\r\n" +
+ "node -1 0 auto_1 dynamic road\\man man nl293 0 headdriver 0 0 enddynamic\r\n" +
+ "endtrainset\r\n";
+
+ private const string Traffic =
+ "node -1 0 nl293_a dynamic road\\man man nl293 \t\tryp_rd110 \t0 headdriver 50 0 enddynamic\r\n";
+
+ private const string WithIncludes =
+ "include tory.scm end\r\n" +
+ "FirstInit\r\n" +
+ "trainset pociag tor_1 0 0\r\n" +
+ "node -1 0 ep07-001 dynamic PKP\\EP07 ep07-001 ep07 0 headdriver 3 0 enddynamic\r\n" +
+ "endtrainset\r\n" +
+ "include tomaszewo/auta.scm end //ruch uliczny\r\n" +
+ "include tomaszewo/eventy.ctr end\r\n" +
+ "/* include tomaszewo/auta.scm end */\r\n" +
+ "include tomaszewo/auta.scm parametr end\r\n";
+
+ private static (string File, string Text)? Files(string name) => name switch
+ {
+ "tory.scm" => (name, Cars),
+ "tomaszewo/auta.scm" => (name, Cars + Traffic),
+ "tomaszewo/eventy.ctr" => (name, "event e1 multiple 0 none endevent\r\n"),
+ _ => null
+ };
+
+ [Fact]
+ public void Includes_after_first_init_that_hold_trainsets_are_put_in_place()
+ {
+ string text = SceneryIncludes.Inline(WithIncludes, Files);
+
+ // Before FirstInit is the scenery itself; without trainsets there is nothing to list.
+ Assert.StartsWith("include tory.scm end\r\n", text);
+ Assert.Contains("include tomaszewo/eventy.ctr end", text);
+ Assert.Contains(SceneryIncludes.BeginMarker + "tomaszewo/auta.scm\r\ntrainset none road1", text);
+ // Commented out, or with parameters to put in: left to the simulator.
+ Assert.Contains("/* include tomaszewo/auta.scm end */", text);
+ Assert.Contains("include tomaszewo/auta.scm parametr end", text);
+ Assert.Single(SceneryIncludes.Ranges(text));
+ }
+
+ [Fact]
+ public void A_trainset_from_an_include_is_listed_with_its_file_and_exported_in_place()
+ {
+ var scenery = TestScenery.Parser().Parse(Path, SceneryIncludes.Inline(WithIncludes, Files));
+
+ Assert.Equal(2, scenery.Trainsets.Count);
+ Assert.Null(scenery.Trainsets[0].SourceFile);
+ Assert.Equal("tomaszewo/auta.scm", scenery.Trainsets[1].SourceFile);
+
+ // Loose road traffic in the file stays put, as written; only its name is known.
+ Assert.Empty(scenery.LooseVehicles);
+ Assert.Contains("nl293_a", scenery.LooseVehicleNames);
+
+ string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
+ Assert.Contains("auto_1", exported);
+ Assert.Contains(Traffic, exported);
+ Assert.True(exported.IndexOf(Traffic, StringComparison.Ordinal) > exported.IndexOf("FirstInit", StringComparison.Ordinal));
+ Assert.DoesNotContain("include tomaszewo/auta.scm end //", exported);
+ }
+
+ [Fact]
+ public void A_trainset_from_an_include_is_saved_into_that_file()
+ {
+ var files = new InMemoryFileSystem()
+ .WithLegacyFile(Path, WithIncludes)
+ .WithLegacyFile("scenery/tomaszewo/auta.scm", Cars + "// koniec\r\n");
+ var scenery = TestScenery.Parser().Parse(Path, SceneryIncludes.Inline(WithIncludes, Files));
+ var log = TestLog.Over(files);
+ var car = scenery.Trainsets[1];
+ car.Vehicles.Clear();
+
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, new SaveSceneryTrainset(files, log).Execute(scenery, car).Outcome);
+
+ Assert.Equal("trainset none road1 -20 50\r\nendtrainset\r\n// koniec\r\n",
+ Cp1250.GetString(files.ReadAllBytes("scenery/tomaszewo/auta.scm")));
+ Assert.Equal(WithIncludes, Cp1250.GetString(files.ReadAllBytes(Path)));
+ Assert.True(files.FileExists("scenery/tomaszewo/auta.scm" + SaveSceneryTrainset.BackupSuffix));
+ }
+
+ [Fact]
+ public void A_utf8_file_stays_utf8_with_its_mark()
+ {
+ var (files, scenery, save) = Setup(new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(Scn)).ToArray());
+ scenery.Trainsets[0].Vehicles.RemoveAt(1);
+
+ save.Execute(scenery, scenery.Trainsets[0]);
+
+ byte[] bytes = files.ReadAllBytes(Path);
+ Assert.Equal(new byte[] { 0xEF, 0xBB, 0xBF }, bytes[..3]);
+ Assert.Contains("żółw", Encoding.UTF8.GetString(bytes[3..]));
+ }
+
+ private const string V1 = "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic";
+ private const string V2 = "node -1 0 v2 dynamic d s m 0 nobody 3 0 enddynamic";
+ private const string V3 = "node -1 0 v3 dynamic d s m 0 nobody 3 0 enddynamic";
+
+ private static string SavedText(string scn, Action? change = null)
+ {
+ var (files, scenery, save) = Setup(scn);
+ change?.Invoke(scenery.Trainsets[0]);
+
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
+
+ return Cp1250.GetString(files.ReadAllBytes(Path));
+ }
+
+ private static IEnumerable VehicleNames(string scn) =>
+ TestScenery.Parser().Parse(Path, scn).Trainsets.Single().Vehicles.Select(v => v.Name);
+
+ private static void Edit(Trainset trainset) => trainset.Vehicles[0].Coupling.Flags = 5;
+
+ [Fact]
+ public void A_vehicle_commented_out_stays_in_its_comment()
+ {
+ string scn = $"FirstInit\ntrainset a t 0 0\n/*\n{V1}\n*/\n{V2}\nendtrainset\n" +
+ "node -1 0 poza dynamic d s m none 0 nobody 0 0 enddynamic\n";
+
+ Assert.Equal(scn, SavedText(scn));
+
+ string edited = SavedText(scn, Edit);
+ Assert.Contains($"/*\n{V1}\n*/\n", edited);
+ var scenery = TestScenery.Parser().Parse(Path, edited);
+ Assert.Equal(new[] { "v2" }, scenery.Trainsets.Single().Vehicles.Select(v => v.Name));
+ Assert.Equal(new[] { "poza" }, scenery.LooseVehicleNames);
+ }
+
+ [Theory]
+ [InlineData("trainset a t 0 0\nNode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
+ [InlineData("trainset a t 0 0\nNODE -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
+ [InlineData("trainset a t 0 0 node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
+ [InlineData("trainset a t 0 0 /*x*/node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n")]
+ public void A_vehicle_is_found_however_it_is_written_and_not_written_twice(string scn)
+ {
+ Assert.Equal(scn, SavedText(scn));
+
+ string edited = SavedText(scn, Edit);
+ var vehicle = Assert.Single(TestScenery.Parser().Parse(Path, edited).Trainsets.Single().Vehicles);
+ Assert.Equal(5, vehicle.Coupling.Flags);
+ }
+
+ [Fact]
+ public void A_vehicle_named_as_a_keyword_is_no_keyword()
+ {
+ string scn = "trainset node node 0 0\nnode -1 0 node dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset\n";
+
+ Assert.Equal(scn, SavedText(scn));
+ Assert.Contains("trainset node node 0 0.1\n", SavedText(scn, t => t.ReadyToGo = true));
+ }
+
+ private const string Commented =
+ "trainset a t 0 0\n" +
+ V1 + "\n" +
+ "// o v2\n" +
+ V2 + " // przy v2\n" +
+ "/* o v3 */\n" +
+ V3 + "\n" +
+ "// koniec\n" +
+ "EndTrainset\n";
+
+ [Fact]
+ public void A_save_with_no_change_leaves_the_vehicles_and_comments_as_they_were()
+ {
+ Assert.Equal(Commented, SavedText(Commented));
+
+ // Nor is anything but the speed touched when only the speed changes.
+ Assert.Equal(Commented.Replace("t 0 0\n", "t 0 0.1\n"), SavedText(Commented, t => t.ReadyToGo = true));
+ }
+
+ [Fact]
+ public void Comments_stay_where_they_are_when_a_vehicle_is_edited()
+ {
+ string edited = SavedText(Commented, t => t.Vehicles[1].Coupling.Flags = 5);
+
+ Assert.Equal(Commented.Replace(V2, V2.Replace("nobody 3", "nobody 5")), edited);
+ }
+
+ [Fact]
+ public void Comments_stay_when_a_vehicle_is_removed()
+ {
+ Assert.Equal(
+ "trainset a t 0 0\n" + V1 + "\n// o v2\n// przy v2\n/* o v3 */\n" + V3 + "\n// koniec\nEndTrainset\n",
+ SavedText(Commented, t => t.Vehicles.RemoveAt(1)));
+
+ Assert.Equal(
+ "trainset a t 0 0\n// o v2\n// przy v2\n/* o v3 */\n// koniec\nEndTrainset\n",
+ SavedText(Commented, t => t.Vehicles.Clear()));
+ }
+
+ [Fact]
+ public void Comments_stay_when_a_vehicle_is_added()
+ {
+ var added = new Dynamic { Name = "nowy", DataFolder = "d", SkinFile = "s", MmdFile = "m" };
+
+ Assert.Equal(
+ Commented.Replace(V3, added.ToTrainsetNode() + V3),
+ SavedText(Commented, t => t.Vehicles.Insert(2, added)));
+
+ Assert.Equal(
+ Commented.Replace("EndTrainset", added.ToTrainsetNode() + "EndTrainset"),
+ SavedText(Commented, t => t.Vehicles.Add(added)));
+ }
+
+ [Fact]
+ public void Comments_stay_when_vehicles_change_places_and_a_moved_one_stays_as_written()
+ {
+ string moved = "node 500 0 v3 dynamic d s m /*w srodku*/ 0 nobody 3 0 enddynamic";
+ string scn = Commented.Replace(V3, moved);
+
+ string saved = SavedText(scn, t => t.Vehicles.Reverse());
+
+ Assert.Equal(new[] { "v3", "v2", "v1" }, VehicleNames(saved));
+ foreach (string comment in new[] { "// o v2\n", " // przy v2\n", "/* o v3 */\n", "// koniec\n" })
+ Assert.Contains(comment, saved);
+ Assert.Contains(moved, saved);
+ }
+
+ [Theory]
+ [InlineData("trainset a /*tor*/ t 100 0\nendtrainset\n", "trainset a /*tor*/ t 100 0.1\nendtrainset\n")]
+ [InlineData("trainset a t /*x*/ 100/*y*/ 0.0 reversed\nendtrainset\n", "trainset a t /*x*/ 100/*y*/ 0.1 reversed\nendtrainset\n")]
+ [InlineData("Trainset a // tor\n t\n 100\n 0.0\nendtrainset\n", "Trainset a // tor\n t\n 100\n 0.1\nendtrainset\n")]
+ public void The_speed_is_found_past_a_comment_in_the_header(string scn, string expected)
+ {
+ Assert.Equal(expected, SavedText(scn, t => t.ReadyToGo = true));
+ }
+
+ [Fact]
+ public void An_optional_include_after_first_init_stays_an_option()
+ {
+ const string scn = "FirstInit\ninclude zima.inc end //$optional 1, Zima\nx\nendoptional\n";
+ string content = $"trainset a t 0 0\n{V1}\nendtrainset\n";
+
+ string text = SceneryIncludes.Inline(scn, name => (name, content));
+
+ Assert.Equal(scn, text);
+ var scenery = TestScenery.Parser().Parse(Path, text);
+ Assert.Equal("Zima", Assert.Single(scenery.Includes).Desc);
+ Assert.Empty(scenery.Trainsets);
+ }
+
+ /// A disk that gives out on whatever is being written; what it does to the file is ' business.
+ public class FailingDisk : DispatchProxy
+ {
+ public InMemoryFileSystem Files = null!;
+
+ protected override object? Invoke(MethodInfo? method, object?[]? args) =>
+ method!.Name == nameof(IFileSystem.WriteAllBytesAtomic)
+ ? throw new IOException("the disk gave out")
+ : method.Invoke(Files, args);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void A_write_that_fails_leaves_the_file_and_its_backup_whole(bool backedUp)
+ {
+ var (files, scenery, _) = Setup(Scn);
+ string backup = Path + SaveSceneryTrainset.BackupSuffix;
+ if (backedUp)
+ files.WithLegacyFile(backup, Scn);
+ var disk = DispatchProxy.Create();
+ ((FailingDisk)(object)disk).Files = files;
+ var log = TestLog.Over();
+ var trainset = scenery.Trainsets[0];
+ string before = trainset.RawEntry;
+ trainset.Vehicles.RemoveAt(1);
+
+ var (outcome, detail) = new SaveSceneryTrainset(disk, log).Execute(scenery, trainset);
+
+ Assert.Equal((SceneryTrainsetSaveOutcome.Failed, "the disk gave out"), (outcome, detail));
+ Assert.Equal(Scn, Cp1250.GetString(files.ReadAllBytes(Path)));
+ Assert.Equal(backedUp, files.FileExists(backup));
+ Assert.Equal(new[] { Path }.Concat(backedUp ? new[] { backup } : Array.Empty()),
+ files.GetFiles("scenery"));
+ Assert.Equal(before, trainset.RawEntry);
+ }
+
+ private static (string Scn, SaveSceneryTrainset Save, Scenery Scenery, string Folder) OnDisk(string text)
+ {
+ string folder = Directory.CreateTempSubdirectory("starter-save-").FullName;
+ string scn = System.IO.Path.Combine(folder, "test.scn");
+ var files = new PhysicalFileSystem();
+ files.WriteAllText(scn, text, LegacyText.CodePage1250);
+ var log = TestLog.Over();
+ return (scn, new SaveSceneryTrainset(files, log), TestScenery.Parser().Parse(scn, text), folder);
+ }
+
+ [Fact]
+ public void On_disk_the_new_text_is_written_beside_the_file_and_moved_over_it()
+ {
+ var (scn, save, scenery, folder) = OnDisk(Scn);
+ try
+ {
+ var trainset = scenery.Trainsets[0];
+ trainset.Vehicles.RemoveAt(1);
+
+ // Nothing can be written beside it: the file is not touched either.
+ string temp = scn + PhysicalFileSystem.TempSuffix;
+ Directory.CreateDirectory(temp);
+ Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, trainset).Outcome);
+ Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn)));
+ Assert.True(File.Exists(scn + SaveSceneryTrainset.BackupSuffix));
+
+ Directory.Delete(temp);
+ Assert.Equal((SceneryTrainsetSaveOutcome.Saved, (string?)null), save.Execute(scenery, trainset));
+
+ Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(scn)));
+ Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn + SaveSceneryTrainset.BackupSuffix)));
+ Assert.Equal(
+ new[] { "test.scn", "test.scn" + SaveSceneryTrainset.BackupSuffix },
+ Directory.GetFiles(folder, "test.scn*").Select(file => System.IO.Path.GetFileName(file)).Order());
+ }
+ finally
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void On_disk_a_file_that_cannot_be_replaced_stays_and_nothing_is_left_beside_it()
+ {
+ // Only Windows keeps a file in use from being moved over.
+ if (!OperatingSystem.IsWindows())
+ return;
+
+ var (scn, save, scenery, folder) = OnDisk(Scn);
+ try
+ {
+ scenery.Trainsets[0].Vehicles.RemoveAt(1);
+
+ using (new FileStream(scn, FileMode.Open, FileAccess.Read, FileShare.Read))
+ Assert.Equal(SceneryTrainsetSaveOutcome.Failed, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
+
+ Assert.Equal(Scn, Cp1250.GetString(File.ReadAllBytes(scn)));
+ Assert.False(File.Exists(scn + PhysicalFileSystem.TempSuffix));
+ }
+ finally
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void On_disk_a_link_stays_a_link_and_its_file_gets_the_text()
+ {
+ var (scn, save, scenery, folder) = OnDisk(Scn);
+ try
+ {
+ string target = scn + ".wlasciwy";
+ File.Move(scn, target);
+ try
+ {
+ File.CreateSymbolicLink(scn, target);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Windows lets only some users make links.
+ return;
+ }
+ scenery.Trainsets[0].Vehicles.RemoveAt(1);
+
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
+
+ Assert.NotNull(new FileInfo(scn).LinkTarget);
+ Assert.DoesNotContain("wagon-1", Cp1250.GetString(File.ReadAllBytes(target)));
+ }
+ finally
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ }
+
+ private static byte[] Bytes(params object[] parts) =>
+ parts.SelectMany(part => part as byte[] ?? Encoding.ASCII.GetBytes((string)part)).ToArray();
+
+ [Fact]
+ public void Bytes_behind_a_mark_that_are_not_utf8_stay_as_they_were()
+ {
+ byte[] mark = { 0xEF, 0xBB, 0xBF };
+ byte[] polish = { 0xB3, 0xF3, 0xBF }; // "łóż" in code page 1250
+ string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n";
+ var (files, scenery, save) = Setup(Bytes(mark, "// ", polish, "\n", trainset, "// ", polish, "\n"));
+ scenery.Trainsets[0].Vehicles.Clear();
+
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
+
+ Assert.Equal(Bytes(mark, "// ", polish, "\ntrainset a t 0 0\nendtrainset\n// ", polish, "\n"), files.ReadAllBytes(Path));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Every_byte_outside_the_vehicles_stays_as_it_was(bool marked)
+ {
+ byte[] mark = marked ? new byte[] { 0xEF, 0xBB, 0xBF } : Array.Empty();
+ byte[] all = Enumerable.Range(0, 256).Select(b => (byte)b).ToArray();
+ string trainset = $"trainset a t 0 0\n{V1}\nendtrainset\n";
+ var (files, scenery, save) = Setup(Bytes(mark, trainset, all));
+ scenery.Trainsets[0].Vehicles.Clear();
+
+ Assert.Equal(SceneryTrainsetSaveOutcome.Saved, save.Execute(scenery, scenery.Trainsets[0]).Outcome);
+
+ Assert.Equal(Bytes(mark, "trainset a t 0 0\nendtrainset\n", all), files.ReadAllBytes(Path));
+ }
+
+ [Theory]
+ // Its own line ends, in a file with the other kind elsewhere.
+ [InlineData("// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n")]
+ // Both kinds in the trainset.
+ [InlineData("trainset a t 0 0\r\n// x\n" + V1 + "\r\nendtrainset\r\n")]
+ public void Mixed_line_ends_stay_as_they_were(string scn)
+ {
+ Assert.Equal(scn, SavedText(scn));
+ }
+
+ [Fact]
+ public void What_the_starter_writes_takes_the_line_ends_of_the_trainset()
+ {
+ string scn = "// x\r\ntrainset a t 0 0\n" + V1 + "\nendtrainset\n// y\r\n";
+
+ Assert.Equal(
+ "// x\r\ntrainset a t 0 0\n" + V2 + "\nendtrainset\n// y\r\n",
+ SavedText(scn, t => t.Vehicles[0].Name = "v2"));
+ }
+}
diff --git a/StarterNG.Tests/SceneryWeatherTests.cs b/StarterNG.Tests/SceneryWeatherTests.cs
index 4e968c4..35a313e 100644
--- a/StarterNG.Tests/SceneryWeatherTests.cs
+++ b/StarterNG.Tests/SceneryWeatherTests.cs
@@ -1,13 +1,10 @@
using StarterNG.Domain.Sceneries;
-using StarterNG.Infrastructure.Sceneries;
using StarterNG.Tests.Fakes;
namespace StarterNG.Tests;
public class SceneryWeatherTests
{
- private static SceneryParser Parser(DateTime now) => new(new FixedClock(now), new StubRandom());
-
[Fact]
public void Authored_weather_is_read_from_the_config_block()
{
@@ -21,7 +18,7 @@ public class SceneryWeatherTests
time 8:30 0 0 endtime
""";
- var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
+ var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
Assert.True(weather.IsAuthored);
Assert.Equal(172, weather.Day);
@@ -35,7 +32,7 @@ public class SceneryWeatherTests
[Fact]
public void A_scenery_without_a_time_starts_at_the_players_clock()
{
- var weather = Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
+ var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", "//$n Test").Weather;
Assert.Equal("17:42", weather.ScenarioTimeOverride);
Assert.False(weather.IsAuthored);
@@ -44,7 +41,7 @@ public class SceneryWeatherTests
[Fact]
public void Freshly_parsed_weather_counts_as_unchanged()
{
- var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
+ var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
Assert.False(weather.Changed);
}
@@ -52,7 +49,7 @@ public class SceneryWeatherTests
[Fact]
public void Editing_then_restoring_returns_to_the_authored_values()
{
- var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
+ var weather = TestScenery.Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", "movelight 100\n").Weather;
weather.Day = 5;
weather.Temperature = -12;
@@ -84,4 +81,168 @@ public class SceneryWeatherTests
Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
}
+
+ private static string Rewritten(string scn) =>
+ SceneryWeatherWriter.Rewrite(scn, new SceneryWeather(), new StubRandom());
+
+ private static string Code(string text) => SceneryCode.Mask(text, out _);
+
+ /// The block the writer puts at the top of the text, and the rest.
+ private static (string Written, string Others) Split(string rewritten)
+ {
+ const string end = "endtime\r\n";
+ int at = rewritten.IndexOf(end, StringComparison.Ordinal) + end.Length;
+ return (rewritten[..at], rewritten[at..]);
+ }
+
+ [Fact]
+ public void A_config_commented_out_stays_off_and_the_one_after_it_is_kept()
+ {
+ // As linia61_osobowy2.scn has them.
+ const string commented = "//config pause yes endconfig //yes: zatrzymanie symulacji zaraz po wczytaniu, jeśli start jako jadący";
+ string scn = $"""
+ sky skydome_clouds.t3d endsky
+ atmo 0 0 0 1000 2000 0.341 0.408 0.471 -1.2 endatmo
+ time 6:43 06:07 21:02 endtime //w Lublińcu 7:30
+
+ {commented}
+ config hiddenevents 1 endconfig
+
+ include linia61/czestochowa.scm end
+
+ """;
+
+ var (written, rest) = Split(Rewritten(scn));
+
+ Assert.Contains("hiddenevents 1\r\n", written);
+ Assert.DoesNotContain("pause", written);
+ Assert.Contains(commented, rest);
+ Assert.Contains("//w Lublińcu 7:30", rest);
+ Assert.Contains("sky skydome_clouds.t3d endsky", rest);
+ Assert.Contains("include linia61/czestochowa.scm end", rest);
+ Assert.DoesNotContain("hiddenevents", rest);
+ foreach (string word in new[] { "atmo", "time", "pause" })
+ Assert.DoesNotContain(word, Code(rest));
+ }
+
+ [Fact]
+ public void A_keyword_in_a_comment_does_not_cut_up_to_the_next_block()
+ {
+ const string scn = """
+ // atmo: tu byla mgla
+ include a.scm end
+ {{0}}
+ /* config */
+ include b.scm end
+ {{1}}
+ // time 8 tu nie
+ include c.scm end
+ atmo 0 0 0 10 2000 0 0 0 0 endatmo
+ config hiddenevents 1 endconfig
+ time 8:30 0 0 endtime
+ """;
+
+ var (written, rest) = Split(Rewritten(scn));
+
+ Assert.Contains("hiddenevents 1", written);
+ foreach (string kept in new[]
+ {
+ "// atmo: tu byla mgla", "include a.scm end", "{{0}}", "/* config */",
+ "include b.scm end", "{{1}}", "// time 8 tu nie", "include c.scm end"
+ })
+ Assert.Contains(kept, rest);
+ foreach (string gone in new[] { "endatmo", "endconfig", "endtime" })
+ Assert.DoesNotContain(gone, rest);
+ }
+
+ [Theory]
+ [InlineData("/* config a 1 endconfig */")]
+ [InlineData("// config a 1 endconfig")]
+ [InlineData("/* atmo 0 0 0 1 2 0 0 0 0 endatmo */")]
+ [InlineData("// atmo 0 0 0 1 2 0 0 0 0 endatmo")]
+ [InlineData("/* time 8:30 0 0 endtime */")]
+ [InlineData("// time 8:30 0 0 endtime")]
+ [InlineData("// movelight 100")]
+ [InlineData("/*\nmovelight 100\n*/")]
+ [InlineData("// scenario.weather.temperature 5")]
+ [InlineData("/* scenario.time.override 6:00 */")]
+ public void What_is_commented_out_stays_as_it_is(string comment)
+ {
+ var (written, rest) = Split(Rewritten($"x\n{comment}\ny\n"));
+
+ Assert.Equal($"x\n{comment}\ny\n", rest);
+ Assert.DoesNotContain("a 1", written);
+ }
+
+ [Fact]
+ public void A_comment_in_a_block_stays_when_the_block_goes()
+ {
+ const string scn = "atmo 0 0 0 // mgla\n10 2000 0 0 0 /* zachmurzenie */ 0 endatmo\n" +
+ "config\n// uwaga\nmovelight 5 // lato\nhiddenevents 1\nendconfig\ninclude a.scm end\n";
+
+ var (written, rest) = Split(Rewritten(scn));
+
+ foreach (string comment in new[] { "// mgla\n", "/* zachmurzenie */", "// uwaga\n", "// lato\n" })
+ Assert.Contains(comment, rest);
+ foreach (string word in new[] { "atmo", "2000", "config", "movelight", "hiddenevents" })
+ Assert.DoesNotContain(word, Code(rest));
+ Assert.Contains("hiddenevents 1\r\n", written);
+ Assert.Contains("include a.scm end", rest);
+ }
+
+ [Fact]
+ public void Settings_of_every_config_are_carried_and_only_the_starters_own_are_replaced()
+ {
+ const string scn = "config movelight 5 hiddenevents 1 endconfig\nCONFIG\nscenario.weather.temperature 3 pause no\nENDCONFIG\n";
+
+ var (written, rest) = Split(Rewritten(scn));
+
+ Assert.Contains("hiddenevents 1\r\n", written);
+ Assert.Contains("pause no\r\n", written);
+ Assert.DoesNotContain("movelight 5", written);
+ Assert.DoesNotContain("temperature 3", written);
+ Assert.Equal("", rest.Trim());
+ }
+
+ [Fact]
+ public void A_word_in_an_include_s_path_is_no_keyword()
+ {
+ const string scn = """
+ include atmo/lasy.scm end
+ include time/8.scm end
+ include config/a.scm end
+ atmo 0 0 0 10 2000 0 0 0 0 endatmo
+ config hiddenevents 1 endconfig
+ """;
+
+ var (_, rest) = Split(Rewritten(scn));
+
+ Assert.Contains("include atmo/lasy.scm end", rest);
+ Assert.Contains("include time/8.scm end", rest);
+ Assert.Contains("include config/a.scm end", rest);
+ Assert.DoesNotContain("endatmo", rest);
+ }
+
+ [Fact]
+ public void Weather_in_comments_is_not_read()
+ {
+ const string scn = """
+ // movelight 7
+ // scenario.weather.temperature 40
+ // scenario.time.override 3:33
+ /* time 4:44 0 0 endtime */
+ // atmo 5 mgla w l61
+ include l61/a.scm end
+ movelight 100
+ atmo 0 0 0 50 900 0 0 0 0.7 endatmo
+ """;
+
+ var weather = TestScenery.Parser(new DateTime(2026, 5, 4, 17, 42, 0)).Parse("scenery/x.scn", scn).Weather;
+
+ Assert.Equal(100, weather.Day);
+ Assert.NotEqual(40, weather.Temperature);
+ Assert.Equal("17:42", weather.ScenarioTimeOverride);
+ Assert.Equal(50, weather.FogStart);
+ Assert.Equal(0.7, weather.Overcast);
+ }
}
diff --git a/StarterNG/Application/Abstractions/ISceneryRepository.cs b/StarterNG/Application/Abstractions/ISceneryRepository.cs
index 031b05b..4990c73 100644
--- a/StarterNG/Application/Abstractions/ISceneryRepository.cs
+++ b/StarterNG/Application/Abstractions/ISceneryRepository.cs
@@ -11,4 +11,7 @@ public interface ISceneryRepository
IReadOnlyList LoadAll(IProgress? progress = null);
Scenery? Load(string path);
+
+ /// The scenery files there are now; null while their folder is away.
+ IReadOnlyList? ListFiles();
}
diff --git a/StarterNG/Application/GameLibrary.cs b/StarterNG/Application/GameLibrary.cs
index 1f94b67..4b76b31 100644
--- a/StarterNG/Application/GameLibrary.cs
+++ b/StarterNG/Application/GameLibrary.cs
@@ -47,10 +47,19 @@ public sealed class GameLibrary
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);
+ // Whatever goes wrong with the rolling stock, the sceneries still get their turn.
+ try
+ {
+ if (_vehicles.Load(Vehicles) == 0)
+ _log.Log("dynamic: brak plikow textures.txt");
+
+ Parallel.Invoke(_minis.Preload, _physics.Preload);
+ }
+ catch (Exception ex)
+ {
+ _log.Log("dynamic: loading the rolling stock", ex);
+ }
ReloadSceneries(progress is null
? null
diff --git a/StarterNG/Application/SaveSceneryTrainset.cs b/StarterNG/Application/SaveSceneryTrainset.cs
new file mode 100644
index 0000000..01d281d
--- /dev/null
+++ b/StarterNG/Application/SaveSceneryTrainset.cs
@@ -0,0 +1,87 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StarterNG.Application.Abstractions;
+using StarterNG.Classes;
+using StarterNG.Domain.Sceneries;
+using StarterNG.Infrastructure.Adapters;
+
+namespace StarterNG.Application;
+
+public enum SceneryTrainsetSaveOutcome
+{
+ Saved,
+
+ /// The file no longer holds the trainset as it was read - edited outside meanwhile.
+ NotFound,
+
+ Failed
+}
+
+///
+/// Writes a consist edited in the depot into the scenery file itself, so it is
+/// there the next time too - not only in the copy written for one start. The
+/// file's first version is kept beside it, once - under a name of the starter's
+/// own, since the simulator's editor keeps its .scn.bak there and rewrites
+/// it on every save.
+///
+public sealed class SaveSceneryTrainset
+{
+ public const string BackupSuffix = ".starter.bak";
+
+ private readonly IFileSystem _files;
+ private readonly IDiagnosticsLog _log;
+
+ public SaveSceneryTrainset(IFileSystem files, IDiagnosticsLog log)
+ {
+ _files = files;
+ _log = log;
+ }
+
+ public (SceneryTrainsetSaveOutcome Outcome, string? Detail) Execute(Scenery scenery, Trainset trainset)
+ {
+ string path = FileOf(scenery, trainset);
+ try
+ {
+ byte[] bytes = _files.ReadAllBytes(path);
+ var (encoding, bom) = LegacyText.Detect(bytes);
+ string text = encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length);
+
+ // The trainset's own line ends, if it has any: a file can hold both kinds.
+ string lines = trainset.RawEntry.Contains('\n') ? trainset.RawEntry : text;
+ string newline = lines.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
+ string entry = SceneryTrainsetText.Compose(trainset, newline);
+ var sameFile = scenery.Trainsets.Where(t => t.SourceFile == trainset.SourceFile).ToList();
+ if (SceneryTrainsetText.Replace(text, trainset, sameFile, entry) is not { } updated)
+ return (SceneryTrainsetSaveOutcome.NotFound, null);
+
+ string backup = path + BackupSuffix;
+ if (!_files.FileExists(backup))
+ _files.WriteAllBytesAtomic(backup, bytes);
+
+ _files.WriteAllBytesAtomic(path, bom.Concat(encoding.GetBytes(updated)).ToArray());
+
+ // Written here, so no change from outside for the scenery to follow.
+ scenery.Sources = new Dictionary(scenery.Sources) { [path] = FileStamp.Of(_files, path) };
+
+ // What the file holds now is what the next save looks for.
+ trainset.RawEntry = entry;
+ trainset.OriginalVelocity = trainset.Velocity;
+ return (SceneryTrainsetSaveOutcome.Saved, null);
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"saving trainset {trainset.Name} to {path}", ex);
+ return (SceneryTrainsetSaveOutcome.Failed, ex.Message);
+ }
+ }
+
+ ///
+ /// The scenery file, or the included one the trainset came from - named, as
+ /// includes are, from the scenery folder the scenery itself is in.
+ ///
+ public static string FileOf(Scenery scenery, Trainset trainset) =>
+ trainset.SourceFile is { } file
+ ? System.IO.Path.Combine(System.IO.Path.GetDirectoryName(scenery.Path) ?? "", file.Replace('\\', '/'))
+ : scenery.Path;
+}
diff --git a/StarterNG/Application/SceneryFileWatch.cs b/StarterNG/Application/SceneryFileWatch.cs
new file mode 100644
index 0000000..fc49207
--- /dev/null
+++ b/StarterNG/Application/SceneryFileWatch.cs
@@ -0,0 +1,204 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using StarterNG.Application.Abstractions;
+using StarterNG.Classes;
+using StarterNG.Domain.Sceneries;
+
+namespace StarterNG.Application;
+
+/// What became of a scenery's files since they were read.
+/// The scenery as it is held; null for a file new in the folder.
+/// As its files have it now; null for one gone from the folder.
+public sealed record SceneryChange(Scenery? Current, Scenery? Fresh);
+
+public enum SceneryVerdict
+{
+ /// Nothing to do about it, for now or for good.
+ Leave,
+
+ /// The library can take it in.
+ Take,
+
+ /// Taking it in loses edits, so that is for someone to say.
+ AskFirst
+}
+
+/// What is to become of a change.
+/// The consists with unsaved edits that is about.
+public sealed record SceneryJudgement(SceneryVerdict Verdict, IReadOnlyList Lost)
+{
+ public static readonly SceneryJudgement Leave = new(SceneryVerdict.Leave, Array.Empty());
+ public static readonly SceneryJudgement Take = new(SceneryVerdict.Take, Array.Empty());
+}
+
+///
+/// Keeps the sceneries in step with their files when those change outside the
+/// starter - in a text editor, in the simulator's editor - without anyone having
+/// to ask for a reload.
+///
+public sealed class SceneryFileWatch
+{
+ private readonly IFileSystem _files;
+ private readonly ISceneryRepository _sceneries;
+ private readonly GameLibrary _library;
+ private readonly AppState _state;
+
+ // Files that would not load, and how they stood then: not tried, and not logged,
+ // again before something about them changes.
+ private readonly Dictionary _unreadable = new();
+
+ private HashSet _away = new();
+
+ public SceneryFileWatch(IFileSystem files, ISceneryRepository sceneries, GameLibrary library, AppState state)
+ {
+ _files = files;
+ _sceneries = sceneries;
+ _library = library;
+ _state = state;
+ }
+
+ /// What has changed among the files of . Off the UI thread, one look at a time.
+ public IReadOnlyList Look(IReadOnlyList held)
+ {
+ var changes = new List();
+ if (_sceneries.ListFiles() is not { } files)
+ return changes;
+
+ var there = files.ToHashSet();
+ var away = new HashSet();
+ foreach (var scenery in held)
+ {
+ if (!there.Contains(scenery.Path))
+ {
+ // A file being replaced is away for a moment: gone only if still so next time.
+ away.Add(scenery.Path);
+ if (_away.Contains(scenery.Path))
+ changes.Add(new SceneryChange(scenery, null));
+ }
+ else if (IsStale(scenery) && LoadOnce(scenery.Path, scenery.Sources.Keys) is { } fresh)
+ {
+ changes.Add(new SceneryChange(scenery, fresh));
+ }
+ }
+ _away = away;
+
+ var known = held.Select(scenery => scenery.Path).ToHashSet();
+ foreach (string file in files.Where(file => !known.Contains(file)))
+ if (LoadOnce(file, new[] { file }) is { } added)
+ changes.Add(new SceneryChange(null, added));
+
+ return changes;
+ }
+
+ /// Whether any of the files it was read from is no longer as it was.
+ public bool IsStale(Scenery scenery) =>
+ scenery.Sources.Any(source => FileStamp.Of(_files, source.Key) != source.Value);
+
+ private Scenery? LoadOnce(string path, IEnumerable sources)
+ {
+ string stood = string.Join('|', sources.Select(source => FileStamp.Of(_files, source)));
+ if (_unreadable.TryGetValue(path, out string? before) && before == stood)
+ return null;
+
+ var loaded = _sceneries.Load(path);
+ if (loaded is null)
+ _unreadable[path] = stood;
+ else
+ _unreadable.Remove(path);
+ return loaded;
+ }
+
+ ///
+ /// Puts what was held into the scenery read anew. A consist whose text is still
+ /// what it was goes over as the object it is, with its edits and its undo steps;
+ /// the choices made by hand go with them.
+ ///
+ /// The consists with unsaved edits that have no place there: their text changed too.
+ public static IReadOnlyList CarryOver(Scenery current, Scenery fresh)
+ {
+ var waiting = current.Trainsets.ToList();
+ for (int i = 0; i < fresh.Trainsets.Count; i++)
+ {
+ var read = fresh.Trainsets[i];
+ int at = waiting.FindIndex(t => t.SourceFile == read.SourceFile && t.RawEntry == read.RawEntry);
+ if (at < 0)
+ continue;
+
+ fresh.Trainsets[i] = waiting[at];
+ waiting.RemoveAt(at);
+ }
+
+ fresh.Weather.KeepChoicesOf(current.Weather);
+ foreach (var include in fresh.Includes)
+ if (current.Includes.FirstOrDefault(i => i.FilePath == include.FilePath) is { } earlier)
+ include.Selected = earlier.Selected;
+
+ return waiting.Where(SceneryTrainsetText.IsModified).ToList();
+ }
+
+ ///
+ /// On the UI thread, once for each change and just before acting on it: what a
+ /// look found may be out of date by then. A scenery read anew has what was held
+ /// put into it here.
+ ///
+ public SceneryJudgement Judge(SceneryChange change)
+ {
+ var sceneries = _library.Sceneries;
+ if (change.Current is not { } current)
+ return sceneries.Any(s => s.Path == change.Fresh!.Path) ? SceneryJudgement.Leave : SceneryJudgement.Take;
+
+ // Saved from here since that look, or reloaded by hand.
+ if (!sceneries.Contains(current) || (change.Fresh is not null && !IsStale(current)))
+ return SceneryJudgement.Leave;
+
+ // One whose file went stays with what was edited in it, for that to be saved elsewhere.
+ if (change.Fresh is not { } fresh)
+ return current.Trainsets.Any(SceneryTrainsetText.IsModified) ? SceneryJudgement.Leave : SceneryJudgement.Take;
+
+ var lost = CarryOver(current, fresh);
+ return lost.Count > 0 ? new SceneryJudgement(SceneryVerdict.AskFirst, lost) : SceneryJudgement.Take;
+ }
+
+ /// On the UI thread: the library, and what is picked in it, take the change in.
+ public void Apply(SceneryChange change)
+ {
+ var sceneries = _library.Sceneries;
+ if (change.Current is not { } current)
+ {
+ sceneries.Add(change.Fresh!);
+ return;
+ }
+
+ if (change.Fresh is not { } fresh)
+ {
+ sceneries.Remove(current);
+ if (ReferenceEquals(_state.CurrentScenery, current))
+ {
+ _state.CurrentScenery = null;
+ _state.CurrentTrainset = null;
+ _state.StartingVehicleName = null;
+ }
+ return;
+ }
+
+ sceneries[sceneries.IndexOf(current)] = fresh;
+ if (ReferenceEquals(_state.CurrentScenery, current))
+ Follow(current, fresh);
+ }
+
+ private void Follow(Scenery current, Scenery fresh)
+ {
+ // Its text changed, the one of its name stands for it, or the one in its place.
+ var picked = _state.CurrentTrainset;
+ var kept = picked is null || fresh.Trainsets.Contains(picked)
+ ? picked
+ : fresh.Trainsets.FirstOrDefault(t => t.Name == picked.Name)
+ ?? fresh.Trainsets.ElementAtOrDefault(current.Trainsets.IndexOf(picked));
+
+ _state.CurrentScenery = fresh;
+ _state.CurrentTrainset = kept;
+ if (kept is null || kept.Vehicles.All(v => v.Name != _state.StartingVehicleName))
+ _state.StartingVehicleName = null;
+ }
+}
diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs
index e17c464..0223661 100644
--- a/StarterNG/Classes/Trainset.cs
+++ b/StarterNG/Classes/Trainset.cs
@@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes;
@@ -58,6 +59,28 @@ public class Trainset
public string? Mini;
+ ///
+ /// "reversed" ahead of the vehicles: the simulator turns the whole trainset round -
+ /// the first vehicle stays at the offset and leads towards the track's point 1,
+ /// every vehicle turned. The author's to set; the starter keeps it as found.
+ ///
+ public bool Reversed { get; private set; }
+
+ ///
+ /// The file the trainset is written in, as the scenery's include names it, for
+ /// one from a file put in place by ;
+ /// null for the scenery's own.
+ ///
+ public string? SourceFile { get; init; }
+
+ ///
+ /// Where the speed, each vehicle and the endtrainset stand in the text, read
+ /// word by word as the fields above are - comments left out, a word taken for a
+ /// field whatever it says. With no endtrainset the end is the end of the text.
+ /// Known only of a trainset that was .
+ ///
+ internal (SceneryCode.Token? Speed, List<(int Start, int End)> Vehicles, int End) Layout { get; private set; }
+
public Trainset(string trainsetEntry)
{
RawEntry = trainsetEntry;
@@ -77,11 +100,7 @@ public class Trainset
private void ParseEntry(string trainsetEntry)
{
- List tokens = Regex
- .Matches(trainsetEntry, @"/\*[\s\S]*?\*/|//[^\r\n]*|[^\s\r\n]+")
- .Select(m => m.Value)
- .Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
- .ToList();
+ List tokens = SceneryCode.Tokens(SceneryCode.Mask(trainsetEntry, out _));
var match = Regex.Match(
trainsetEntry,
@@ -99,66 +118,92 @@ public class Trainset
if (mini.Success)
Mini = mini.Groups[1].Value.Trim();
+ // The simulator reads its words in lower case: "Trainset" is a trainset too.
+ bool Is(int at, string word) => tokens[at].Is(word);
+
+ SceneryCode.Token? speed = null;
+ var spans = new List<(int Start, int End)>();
+ int end = trainsetEntry.Length;
+
for (int i = 0; i < tokens.Count; i++)
{
- if (tokens[i] == "endtrainset")
- break;
-
- if (tokens[i] == "trainset")
+ if (Is(i, "endtrainset"))
{
- this.Name = tokens[++i];
- this.Track = tokens[++i];
- this.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
- this.Velocity = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
+ end = tokens[i].Index;
+ break;
+ }
+
+ if (Is(i, "trainset"))
+ {
+ this.Name = tokens[++i].Text;
+ this.Track = tokens[++i].Text;
+ this.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
+ speed = tokens[++i];
+ this.Velocity = float.Parse(tokens[i].Text, CultureInfo.InvariantCulture);
this.OriginalVelocity = this.Velocity;
continue;
}
- if (tokens[i] == "assignments")
+ // Ahead of the vehicles; the simulator refuses it after the first one.
+ if (Is(i, "reversed"))
{
- while (i < tokens.Count && tokens[i] != "endassignment")
- i++;
- i++;
+ Reversed |= Vehicles.Count == 0;
continue;
}
- if (tokens[i] == "node")
+ if (Is(i, "assignment") || Is(i, "assignments"))
{
+ while (i < tokens.Count && !Is(i, "endassignment"))
+ i++;
+ continue;
+ }
+
+ if (Is(i, "node"))
+ {
+ int start = tokens[i].Index;
Dynamic nodeDynamic = new Dynamic();
- nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
- nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
- nodeDynamic.Name = tokens[++i];
+ nodeDynamic.RangeMax = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
+ nodeDynamic.RangeMin = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
+ nodeDynamic.Name = tokens[++i].Text;
i++;
- nodeDynamic.DataFolder = tokens[++i];
- nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
- nodeDynamic.MmdFile = tokens[++i];
- nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
+ nodeDynamic.DataFolder = tokens[++i].Text;
+ nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i].Text);
+ nodeDynamic.MmdFile = tokens[++i].Text;
+ nodeDynamic.Offset = float.Parse(tokens[++i].Text, CultureInfo.InvariantCulture);
- nodeDynamic.DriverType = tokens[++i] switch
- {
- "headdriver" => eDriverType.Headdriver,
- "reardriver" => eDriverType.Reardriver,
- "passenger" => eDriverType.Passenger,
- _ => eDriverType.Nobody
- };
+ nodeDynamic.DriverType = Dynamic.ParseDriver(tokens[++i].Text);
- nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
+ nodeDynamic.Coupling = Coupling.Parse(tokens[++i].Text);
+ // A missing enddynamic does not take the next vehicle with it.
var trailing = new List();
- while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
- trailing.Add(tokens[++i]);
+ while (i + 1 < tokens.Count && !Is(i + 1, "enddynamic") && !Is(i + 1, "node") &&
+ !Is(i + 1, "endtrainset"))
+ trailing.Add(tokens[++i].Text);
nodeDynamic.ReadTrailing(trailing);
- i++;
+ if (i + 1 < tokens.Count && Is(i + 1, "enddynamic"))
+ i++;
Vehicles.Add(nodeDynamic);
+ spans.Add((start, tokens[i].End));
}
}
+
+ Layout = (speed, spans, end);
}
public string ToSceneryEntry() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
+ /// No vehicles, in the export too: an entry that could not be read goes out as its raw text.
+ public void RemoveVehicles()
+ {
+ Vehicles = new List();
+ if (!Parsed)
+ RawEntry = string.Empty;
+ }
+
public string GetTrainsetEntry()
{
@@ -177,13 +222,11 @@ public class Trainset
sb.Append(Track).Append(' ');
sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
+ if (Reversed)
+ sb.Append(" reversed");
sb.Append('\n');
- string entry = sb.ToString();
- foreach (Dynamic vehicle in Vehicles)
- entry += vehicle.ToTrainsetNode();
-
- return entry;
+ return sb.Append(Dynamic.ToTrainsetNodes(Vehicles)).ToString();
}
}
@@ -201,12 +244,6 @@ public class Dynamic
public Coupling Coupling = new();
- public byte couplingData
- {
- get => (byte)(Coupling.AbsFlags & 0xFF);
- set => Coupling.Flags = value;
- }
-
public const string PantState = "pantstate";
public const int PantStateMax = 3;
@@ -244,8 +281,6 @@ public class Dynamic
public static bool IsPantStateType(string? type) =>
string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
- public bool IsPantState => IsPantStateType(LoadType);
-
internal void ReadTrailing(List t)
{
int p = 0;
@@ -278,14 +313,33 @@ public class Dynamic
return dot > 0 ? token[..dot] : token;
}
+ internal static eDriverType ParseDriver(string word) => word.ToLowerInvariant() switch
+ {
+ "headdriver" => eDriverType.Headdriver,
+ "reardriver" => eDriverType.Reardriver,
+ "passenger" => eDriverType.Passenger,
+ _ => eDriverType.Nobody
+ };
+
+ private string DriverWord => DriverType switch
+ {
+ eDriverType.Headdriver => "headdriver",
+ eDriverType.Reardriver => "reardriver",
+ eDriverType.Passenger => "passenger",
+ _ => "nobody"
+ };
+
internal string WriteTrailing()
{
var sb = new StringBuilder();
- bool loaded = !string.IsNullOrEmpty(LoadType);
+ bool loaded = !string.IsNullOrEmpty(LoadType) && LoadCount > 0;
sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
- if (loaded && (LoadCount > 0 || IsPantState))
+ // The simulator reads a type only after a count above 0. One after a 0 it
+ // takes for a destination, and with it the next vehicle, up to its enddynamic.
+ // Pantographs set to none are the simulator's default anyway.
+ if (loaded)
sb.Append(' ').Append(LoadType);
return sb.ToString();
@@ -314,37 +368,26 @@ public class Dynamic
public string ToTrainsetNode()
{
- string driver = DriverType switch
- {
- eDriverType.Headdriver => "headdriver",
- eDriverType.Reardriver => "reardriver",
- eDriverType.Passenger => "passenger",
- _ => "nobody"
- };
-
return
- $"node {RangeMax} {RangeMin} {Name} dynamic " +
+ $"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
+ $"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} " +
$"{Offset.ToString(CultureInfo.InvariantCulture)} " +
- $"{driver} {Coupling}{WriteTrailing()} enddynamic\n";
+ $"{DriverWord} {Coupling}{WriteTrailing()} enddynamic\n";
}
+ public static string ToTrainsetNodes(IEnumerable vehicles) =>
+ string.Concat(vehicles.Select(v => v.ToTrainsetNode()));
+
public string ToLooseNode()
{
- string driver = DriverType switch
- {
- eDriverType.Headdriver => "headdriver",
- eDriverType.Reardriver => "reardriver",
- eDriverType.Passenger => "passenger",
- _ => "nobody"
- };
string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
return
$"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
$"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
$"{DataFolder} {SkinFile} {MmdFile} {path} " +
- $"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" +
+ $"{Offset.ToString(CultureInfo.InvariantCulture)} {DriverWord} {vel}" +
$"{WriteTrailing()} enddynamic\n";
}
}
diff --git a/StarterNG/Domain/Sceneries/FileStamp.cs b/StarterNG/Domain/Sceneries/FileStamp.cs
new file mode 100644
index 0000000..674bca0
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/FileStamp.cs
@@ -0,0 +1,23 @@
+using System;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Domain.Sceneries;
+
+/// A file as it stood, to tell whether it has been changed since.
+public readonly record struct FileStamp(DateTime LastWriteUtc, long Size)
+{
+ /// Null for one that is not there, or cannot be looked at just now.
+ public static FileStamp? Of(IFileSystem files, string path)
+ {
+ try
+ {
+ return files.FileExists(path)
+ ? new FileStamp(files.GetLastWriteTimeUtc(path), files.GetFileSize(path))
+ : null;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+}
diff --git a/StarterNG/Domain/Sceneries/Scenery.cs b/StarterNG/Domain/Sceneries/Scenery.cs
index 243ab2f..07b0d32 100644
--- a/StarterNG/Domain/Sceneries/Scenery.cs
+++ b/StarterNG/Domain/Sceneries/Scenery.cs
@@ -1,11 +1,9 @@
-using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
-using StarterNG.Domain;
namespace StarterNG.Domain.Sceneries;
@@ -59,10 +57,16 @@ public sealed class Scenery
public List LooseVehicleNames { get; } = new();
- public List Faults { get; } = new();
+ public List Faults { get; } = new();
public SceneryWeather Weather { get; } = new();
+ ///
+ /// The files it was read from, as each stood then: its own and those put in place
+ /// of their includes. Replaced whole, as it is looked at off the UI thread too.
+ ///
+ public IReadOnlyDictionary Sources { get; set; } = new Dictionary();
+
public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
public string LocalizedDescription(ISceneryTranslations translations) =>
@@ -81,7 +85,9 @@ public sealed class Scenery
for (int i = 0; i < Trainsets.Count; i++)
{
- string entry = skipDecorTrainsets && Trainsets[i].Decor
+ // The simulator logs an empty trainset as a bad one and does nothing else with it.
+ bool empty = Trainsets[i].Parsed && Trainsets[i].Vehicles.Count == 0;
+ string entry = empty || (skipDecorTrainsets && Trainsets[i].Decor)
? string.Empty
: Trainsets[i].ToSceneryEntry();
result = result.Replace("{{" + i + "}}", entry);
diff --git a/StarterNG/Domain/Sceneries/SceneryCode.cs b/StarterNG/Domain/Sceneries/SceneryCode.cs
new file mode 100644
index 0000000..05d8e4e
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/SceneryCode.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Collections.Generic;
+using System.Text.RegularExpressions;
+
+namespace StarterNG.Domain.Sceneries;
+
+///
+/// A scenery's text read the way the simulator's parser reads it: comments -
+/// // to the end of the line, /* */ anywhere, even glued to a word -
+/// left out, and the rest split into words at spaces, tabs, line ends and
+/// semicolons. A trainset commented out is no trainset.
+///
+public static class SceneryCode
+{
+ public readonly record struct Token(string Text, int Index)
+ {
+ public int End => Index + Text.Length;
+
+ public bool Is(string word) => string.Equals(Text, word, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static readonly Regex Word = new(@"[^\s;]+");
+
+ ///
+ /// The text with every comment blanked out, line ends kept, so a place in one
+ /// is the same place in the other.
+ ///
+ /// Where a /* that never ends starts, or -1.
+ public static string Mask(string text, out int unclosed)
+ {
+ unclosed = -1;
+ char[]? masked = null;
+
+ for (int i = 0; i < text.Length; i++)
+ {
+ char c = text[i];
+ if (c == '"')
+ {
+ // Quotes glue what they enclose into one word, comment marks and all.
+ int close = text.IndexOf('"', i + 1);
+ i = close < 0 ? text.Length : close;
+ continue;
+ }
+
+ if (c != '/' || i + 1 >= text.Length)
+ continue;
+
+ int end;
+ if (text[i + 1] == '/')
+ {
+ end = text.IndexOf('\n', i + 2);
+ if (end < 0)
+ end = text.Length;
+ }
+ else if (text[i + 1] == '*')
+ {
+ int close = text.IndexOf("*/", i + 2, StringComparison.Ordinal);
+ if (close < 0)
+ unclosed = i;
+ end = close < 0 ? text.Length : close + 2;
+ }
+ else
+ {
+ continue;
+ }
+
+ masked ??= text.ToCharArray();
+ for (int j = i; j < end; j++)
+ if (masked[j] != '\n' && masked[j] != '\r')
+ masked[j] = ' ';
+ i = end - 1;
+ }
+
+ return masked is null ? text : new string(masked);
+ }
+
+ /// Text with its comments blanked out by .
+ public static List Tokens(string code)
+ {
+ var tokens = new List();
+ for (var m = Word.Match(code); m.Success; m = m.NextMatch())
+ tokens.Add(new Token(m.Value, m.Index));
+ return tokens;
+ }
+}
diff --git a/StarterNG/Domain/Sceneries/SceneryFault.cs b/StarterNG/Domain/Sceneries/SceneryFault.cs
new file mode 100644
index 0000000..2fd42ef
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/SceneryFault.cs
@@ -0,0 +1,104 @@
+namespace StarterNG.Domain.Sceneries;
+
+public enum SceneryFaultKind
+{
+ /// /* never closed: the simulator skips the rest of the file.
+ UnclosedComment,
+
+ /// A trainset with no endtrainset; the simulator ends it at the next one.
+ TrainsetNotClosed,
+
+ ///
+ /// A trainset's header written twice: the simulator ends the first as an empty
+ /// trainset, noting it in its log, and reads the trainset under the second.
+ ///
+ TrainsetHeaderRepeated,
+
+ /// The header lacks its name, track, offset or speed.
+ TrainsetIncomplete,
+
+ StrayEndTrainset,
+
+ /// reversed outside a trainset, or after its first vehicle.
+ ReversedMisplaced,
+
+ /// A vehicle with no enddynamic, which swallows what follows.
+ VehicleNotClosed,
+
+ VehicleIncomplete,
+
+ ///
+ /// A vehicle with no load count: the simulator takes its enddynamic for
+ /// one and goes on to the next.
+ ///
+ LoadCountMissing,
+
+ ///
+ /// A load count other than 0 with no type after it - the simulator takes the
+ /// enddynamic for the type; holds the count.
+ ///
+ LoadTypeMissing,
+
+ ///
+ /// One word between the load and enddynamic: the simulator takes it for
+ /// a destination, the enddynamic for its name, and goes on to the next.
+ /// holds the word.
+ ///
+ WordBeforeEnd,
+
+ /// A vehicle written as a trainset's is, with no trainset around it.
+ VehicleOutsideTrainset,
+
+ ///
+ /// A trainset's track named with a space, which splits it into words; the
+ /// holds the name as written.
+ ///
+ TrackNameSplit,
+
+ /// Text where a number belongs; holds it.
+ NotANumber,
+
+ /// An include whose file is not there; names it.
+ MissingInclude,
+
+ ///
+ /// 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
+ /// holds the name on disk.
+ ///
+ IncludeLetterCase,
+
+ /// An include with no end, which swallows what follows it.
+ IncludeNotClosed,
+
+ /// A trainset the starter could not read, for a reason none of the above names.
+ TrainsetUnreadable,
+
+ /// A vehicle outside a trainset the starter could not read.
+ VehicleUnreadable
+}
+
+///
+/// A mistake in a scenery's text: where it is - the scenery's file or one it
+/// includes, and the line, as an editor counts them - and what it concerns.
+///
+/// The trainset or vehicle it concerns, when it has a name.
+public sealed record SceneryFault(SceneryFaultKind Kind, string File, int Line, string? Subject = null,
+ string? Token = null)
+{
+ /// Where in the scenery's text it was found, to tell which trainset it falls in.
+ public int Offset { get; init; }
+
+ ///
+ /// What the simulator does not read because of it - the vehicles, trainsets and
+ /// includes it takes as part of this entry - or null when it loses none.
+ ///
+ public string? Lost { get; init; }
+
+ ///
+ /// The trainsets the simulator does not make because of it, their headers
+ /// swallowed by it or by what it set going - named, or placed when unnamed -
+ /// or null when there are none.
+ ///
+ public string? Unmade { get; init; }
+}
diff --git a/StarterNG/Domain/Sceneries/SceneryFaultText.cs b/StarterNG/Domain/Sceneries/SceneryFaultText.cs
new file mode 100644
index 0000000..e29bc85
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/SceneryFaultText.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace StarterNG.Domain.Sceneries;
+
+/// A scenery's faults as they are read: where each one is, in a column, and what it is.
+public static class SceneryFaultText
+{
+ /// Most it lists; the rest are only counted.
+ /// The wording for a key, in the language shown.
+ public static string Format(IReadOnlyList faults, int limit, Func text)
+ {
+ if (faults.Count == 0)
+ return "";
+
+ var shown = faults.Take(limit).Select(f => (Where: $"{f.File}:{f.Line}", Fault: f)).ToList();
+ int width = shown.Max(f => f.Where.Length) + 2;
+ var lines = new StringBuilder();
+ foreach (var (where, fault) in shown)
+ {
+ lines.Append(where.PadRight(width)).Append(Describe(fault, text)).Append('\n');
+ // What it costs, under it: too long to follow on the same line.
+ if (fault.Lost is { } lost)
+ lines.Append(' ', width).Append(string.Format(text("SyntaxLost"), lost)).Append('\n');
+ if (fault.Unmade is { } unmade)
+ lines.Append(' ', width).Append(string.Format(text("SyntaxUnmade"), unmade)).Append('\n');
+ }
+ if (faults.Count > limit)
+ lines.Append(string.Format(text("SyntaxMore"), faults.Count - limit));
+ return lines.ToString().TrimEnd();
+ }
+
+ private static string Describe(SceneryFault fault, Func text) =>
+ string.Format(text("Syntax" + fault.Kind), fault.Subject ?? "?", fault.Token ?? "");
+}
diff --git a/StarterNG/Domain/Sceneries/SceneryIncludes.cs b/StarterNG/Domain/Sceneries/SceneryIncludes.cs
new file mode 100644
index 0000000..08181b5
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/SceneryIncludes.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace StarterNG.Domain.Sceneries;
+
+///
+/// The scenario's own files - included after FirstInit - put in place of their
+/// include line when they hold trainsets, as the old starter did. Decorations
+/// and road traffic live there; inlined, their consists are listed, can be
+/// started from, and the copy written for a start carries them instead of the
+/// include, so they are not loaded twice.
+///
+///
+/// Only the plain include path end form: an include with parameters needs
+/// them put in, and one whose end is not on its line is read by the
+/// simulator in ways not worth guessing. One marked $optional stays too:
+/// whether it is loaded is the player's to choose. Each inlined file is wrapped in
+/// marker comments, which is how a trainset is told to belong to it.
+///
+public static class SceneryIncludes
+{
+ public const string BeginMarker = "//$starter-include ";
+ public const string EndMarker = "//$starter-endinclude";
+
+ private static readonly Regex FirstInit = new(@"\bFirstInit\b", RegexOptions.IgnoreCase);
+
+ private static readonly Regex Include = new(
+ @"^[ \t]*include[ \t]+(\S+)[ \t]+end\b[^\r\n]*", RegexOptions.Multiline | RegexOptions.IgnoreCase);
+
+ private static readonly Regex HasTrainset = new(@"^[ \t]*trainset\b", RegexOptions.Multiline | RegexOptions.IgnoreCase);
+
+ private static readonly Regex BlockComment = new(@"/\*[\s\S]*?\*/");
+
+ private static readonly Regex Marked = new(
+ Regex.Escape(BeginMarker) + @"(\S+)[\s\S]*?" + Regex.Escape(EndMarker));
+
+ ///
+ /// A file named as an include names it: its name as it is on disk, which the
+ /// marker carries, and its text - or null when there is none.
+ ///
+ public static string Inline(string text, Func read)
+ {
+ var firstInit = FirstInit.Match(text);
+ if (!firstInit.Success)
+ return text;
+
+ string head = text[..firstInit.Index];
+ string tail = text[firstInit.Index..];
+ string newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
+ var comments = BlockComment.Matches(tail).Select(m => (m.Index, End: m.Index + m.Length)).ToList();
+
+ return head + Include.Replace(tail, m =>
+ {
+ if (comments.Any(c => m.Index >= c.Index && m.Index < c.End))
+ return m.Value;
+
+ if (m.Value.Contains("$optional", StringComparison.OrdinalIgnoreCase))
+ return m.Value;
+
+ if (read(m.Groups[1].Value) is not var (file, content) || !HasTrainset.IsMatch(content))
+ return m.Value;
+
+ return BeginMarker + file + newline + content.TrimEnd() + newline + EndMarker;
+ });
+ }
+
+ /// The text without the inlined files: what the scenery itself says.
+ public static string WithoutInlined(string text) => Marked.Replace(text, "");
+
+ /// Where each inlined file sits in the text, by the name its include gave.
+ public static IReadOnlyList<(int Start, int End, string File)> Ranges(string text) =>
+ Marked.Matches(text).Select(m => (m.Index, m.Index + m.Length, m.Groups[1].Value)).ToList();
+
+ public static string? FileAt(IReadOnlyList<(int Start, int End, string File)> ranges, int index) =>
+ ranges.FirstOrDefault(r => index >= r.Start && index < r.End).File;
+}
diff --git a/StarterNG/Domain/Sceneries/SceneryLines.cs b/StarterNG/Domain/Sceneries/SceneryLines.cs
new file mode 100644
index 0000000..61a98e1
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/SceneryLines.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Collections.Generic;
+
+namespace StarterNG.Domain.Sceneries;
+
+///
+/// Which file and line a place in a scenery's text is - its own file, or one put
+/// in place of its include by , counted from that
+/// file's own first line.
+///
+public sealed class SceneryLines
+{
+ private readonly List _starts = new();
+ private readonly List<(string File, int Line)> _lines = new();
+
+ public SceneryLines(string text, string file)
+ {
+ var outer = new Stack<(string File, int Line)>();
+ string current = file;
+ int number = 1;
+
+ for (int start = 0; ;)
+ {
+ int end = text.IndexOf('\n', start);
+ if (end < 0)
+ end = text.Length;
+ var line = text.AsSpan(start, end - start);
+
+ _starts.Add(start);
+ if (line.StartsWith(SceneryIncludes.BeginMarker, StringComparison.Ordinal))
+ {
+ // The marker stands where the include was; the file's lines follow.
+ _lines.Add((current, number));
+ outer.Push((current, number));
+ current = line[SceneryIncludes.BeginMarker.Length..].Trim().ToString();
+ number = 1;
+ }
+ else if (line.StartsWith(SceneryIncludes.EndMarker, StringComparison.Ordinal) && outer.Count > 0)
+ {
+ (current, number) = outer.Pop();
+ _lines.Add((current, number++));
+ }
+ else
+ {
+ _lines.Add((current, number++));
+ }
+
+ if (end == text.Length)
+ break;
+ start = end + 1;
+ }
+ }
+
+ public (string File, int Line) At(int index)
+ {
+ int found = _starts.BinarySearch(index);
+ return _lines[Math.Max(0, found >= 0 ? found : ~found - 1)];
+ }
+
+ /// A mistake at a place in the text, told by its file and line.
+ public SceneryFault Fault(SceneryFaultKind kind, int at, string? subject = null, string? token = null)
+ {
+ var (file, line) = At(at);
+ return new SceneryFault(kind, file, line, subject, token) { Offset = at };
+ }
+}
diff --git a/StarterNG/Domain/Sceneries/ScenerySyntax.cs b/StarterNG/Domain/Sceneries/ScenerySyntax.cs
new file mode 100644
index 0000000..a943d32
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/ScenerySyntax.cs
@@ -0,0 +1,528 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace StarterNG.Domain.Sceneries;
+
+///
+/// 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.
+///
+public readonly record struct IncludeLookup(bool Opens, string? OnDisk = null);
+
+///
+/// Goes through a scenery's text for the parts the starter reads - trainsets,
+/// vehicles, includes - finding where each trainset and loose vehicle is, and
+/// what in them is written wrong. Whatever else the text holds is passed over.
+///
+///
+/// The text is read twice. Once word by word as the simulator reads it, which
+/// takes every field it expects whatever stands there, and after a vehicle
+/// written wrong goes on to the next enddynamic - that finds the faults,
+/// and what each makes the simulator lose. Once as the author meant it, each
+/// entry ending where it was meant to - that finds the trainsets and vehicles,
+/// so one the simulator would lose is still listed, and written right when
+/// the starter writes it.
+///
+public static class ScenerySyntax
+{
+ public sealed record Result(
+ IReadOnlyList<(int Start, int End)> Trainsets,
+ IReadOnlyList<(int Start, int End)> LooseVehicles,
+ IReadOnlyList Faults,
+ SceneryLines Lines);
+
+ /// The scenery's text, its includes put in place.
+ /// The scenery's file name, for faults in its own text.
+ ///
+ /// What the simulator makes of an include, named as the include names it; null
+ /// leaves includes unchecked.
+ ///
+ public static Result Scan(string text, string file, Func? findInclude = 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);
+ simulator.Run();
+ var author = new Reading(tokens, lines, null, asSimulator: false);
+ author.Run();
+
+ var faults = Unmade(simulator, author, lines);
+ if (unclosed >= 0)
+ faults.Insert(0, lines.Fault(SceneryFaultKind.UnclosedComment, unclosed));
+
+ return new Result(author.Trainsets.Select(t => (t.Start, t.End)).ToList(), author.Loose, faults, lines);
+ }
+
+ ///
+ /// The simulator's faults, with those in a trainset whose header it swallowed
+ /// left out: they come of reading its vehicles with no trainset, and the
+ /// trainset is told of instead, with the fault it came of - the first of a
+ /// run of them, where the run started.
+ ///
+ private static List Unmade(Reading simulator, Reading author, SceneryLines lines)
+ {
+ var unmade = new List<(int Start, int End, int Cause)>();
+ var lost = new Dictionary>();
+ foreach (var (start, end, name) in author.Trainsets)
+ {
+ int cause = -1;
+ foreach (var (from, to, at) in simulator.Swallowed)
+ if (start >= from && start <= to)
+ {
+ cause = at;
+ break;
+ }
+ if (cause < 0)
+ continue;
+ foreach (var earlier in unmade)
+ if (cause >= earlier.Start && cause < earlier.End)
+ {
+ cause = earlier.Cause;
+ break;
+ }
+ unmade.Add((start, end, cause));
+
+ var (file, line) = lines.At(start);
+ bool named = name.Length > 0 && !name.Equals("none", StringComparison.OrdinalIgnoreCase);
+ if (!lost.TryGetValue(cause, out var names))
+ lost[cause] = names = new List();
+ names.Add(named ? name : $"{file}:{line}");
+ }
+
+ if (unmade.Count == 0)
+ return simulator.Faults;
+
+ var faults = new List();
+ foreach (var fault in simulator.Faults)
+ {
+ if (unmade.Any(u => fault.Offset >= u.Start && fault.Offset < u.End))
+ continue;
+ faults.Add(fault.Lost is not null && lost.Remove(fault.Offset, out var names)
+ ? fault with { Unmade = Listed(names) }
+ : fault);
+ }
+ return faults;
+ }
+
+ private static string Listed(List entries)
+ {
+ const int Named = 6;
+ string named = string.Join(", ", entries.Take(Named));
+ return entries.Count > Named ? $"{named} … +{entries.Count - Named}" : named;
+ }
+
+ private sealed class Reading(
+ List tokens, SceneryLines lines, Func? findInclude, bool asSimulator)
+ {
+ public readonly List<(int Start, int End, string Name)> Trainsets = new();
+ public readonly List<(int, int)> Loose = new();
+ public readonly List Faults = new();
+
+ /// The text each fault makes the simulator pass over - from, to - and where the fault is.
+ public readonly List<(int From, int To, int At)> Swallowed = new();
+
+ // The trainset being read: where it starts, its name, how many vehicles so
+ // far, and the word its header starts at.
+ private (int Index, string Name, int Vehicles, int At)? _open;
+
+ public void Run()
+ {
+ for (int i = 0; i < tokens.Count; i++)
+ {
+ var token = tokens[i];
+
+ if (token.Is("trainset"))
+ i = Header(i);
+ else if (token.Is("endtrainset"))
+ {
+ // One with no vehicles is no mistake: a place for the player's own
+ // consist, filled in the depot.
+ if (_open is { } ended)
+ Trainsets.Add((ended.Index, token.End, ended.Name));
+ else
+ Fault(SceneryFaultKind.StrayEndTrainset, token.Index);
+ _open = null;
+ }
+ else if (token.Is("reversed"))
+ {
+ // Turns the trainset round only while none of its vehicles is placed yet.
+ if (_open is not { Vehicles: 0 })
+ Fault(SceneryFaultKind.ReversedMisplaced, token.Index);
+ }
+ else if (token.Is("assignment") || token.Is("assignments"))
+ {
+ while (i + 1 < tokens.Count && !tokens[i].Is("endassignment"))
+ i++;
+ }
+ else if (token.Is("node"))
+ {
+ // Not a vehicle: only its kind is stepped over, what follows is its own.
+ if (i + 4 >= tokens.Count || !tokens[i + 4].Is("dynamic"))
+ i += 4;
+ else
+ i = Vehicle(i);
+ }
+ else if (token.Is("include"))
+ i = Include(i);
+ }
+
+ if (_open is { } last)
+ NotClosed(last);
+ }
+
+ // Unless its header is told incomplete already: the endtrainset taken for a field.
+ private void NotClosed((int Index, string Name, int Vehicles, int At) trainset)
+ {
+ if (!Faults.Any(f => f.Offset == trainset.Index))
+ Fault(SceneryFaultKind.TrainsetNotClosed, trainset.Index, trainset.Name);
+ }
+
+ // Each reader below takes the entry starting at the given word and gives
+ // back the last word it took.
+
+ private int Header(int at)
+ {
+ var token = tokens[at];
+ if (_open is { } unended)
+ {
+ // The same header twice over, the first with nothing under it: the
+ // simulator ends that one as empty - noting it in its log - and reads on.
+ if (unended.Vehicles == 0 && Same(unended.At, at, 4))
+ Fault(SceneryFaultKind.TrainsetHeaderRepeated, unended.Index, unended.Name);
+ else
+ NotClosed(unended);
+ }
+ _open = null;
+
+ if (!Fields(at, 4))
+ {
+ // Still opened, so its endtrainset ends it rather than standing alone.
+ int given = 0;
+ while (given < 4 && at + given + 1 < tokens.Count && !Keyword(tokens[at + given + 1]))
+ given++;
+ string? partial = given > 0 ? tokens[at + 1].Text : null;
+ _open = (token.Index, partial ?? "", 0, at);
+ if (!asSimulator)
+ return at + given;
+
+ // The simulator takes four words, whatever they are.
+ int taken = Math.Min(at + 4, tokens.Count - 1);
+ Fault(SceneryFaultKind.TrainsetIncomplete, token.Index, partial, swallowed: (at + given, taken));
+ return taken;
+ }
+
+ string name = tokens[at + 1].Text;
+ if (SplitTrack(tokens, at) is { } words)
+ {
+ // Its offset is the rest of the track's name - a lost letter, often.
+ string track = string.Join(' ', tokens.Skip(at + 2).Take(words).Select(t => t.Text));
+ Fault(SceneryFaultKind.TrackNameSplit, tokens[at + 2].Index, name, track);
+ }
+ else
+ {
+ Number(tokens[at + 3], name);
+ Number(tokens[at + 4], name);
+ }
+ _open = (token.Index, name, 0, at);
+ return at + 4;
+ }
+
+ private int Vehicle(int at)
+ {
+ bool inTrainset = _open is not null;
+ // node range range name dynamic, then folder skin mmd [track] offset driver [coupling | speed]
+ int fields = inTrainset ? 10 : 11;
+ string name = tokens[at + 3].Text;
+ bool complete = Fields(at, fields);
+
+ // Where the author meant it to end: its enddynamic, or with that missing
+ // the word before whatever comes next.
+ int meant = at + 5;
+ for (; meant < tokens.Count && !Keyword(tokens[meant]); meant++)
+ if (tokens[meant].Is("enddynamic"))
+ break;
+ bool closed = meant < tokens.Count && tokens[meant].Is("enddynamic");
+ if (!closed)
+ meant--;
+
+ if (!asSimulator)
+ return ListVehicle(at, complete, closed, meant);
+
+ if (at + fields + 1 >= tokens.Count)
+ {
+ Fault(complete ? SceneryFaultKind.VehicleNotClosed : SceneryFaultKind.VehicleIncomplete,
+ tokens[at].Index, name);
+ return tokens.Count - 1;
+ }
+
+ var (fault, trackless) = CheckFields(at, complete, inTrainset, name);
+ if (fault is null && !closed)
+ fault = SceneryFaultKind.VehicleNotClosed;
+
+ int end = LoadEnd(at + fields + 1, trackless, meant, ref fault, out string? faulty);
+
+ if (fault is { } kind)
+ Fault(kind, tokens[at].Index, name, faulty, end > meant ? (meant, end) : null);
+
+ if (_open is { } open)
+ _open = open with { Vehicles = open.Vehicles + 1 };
+ return end;
+ }
+
+ // As the author meant it: there to be listed, whatever is wrong with it.
+ private int ListVehicle(int at, bool complete, bool closed, int meant)
+ {
+ // Without even the fields every vehicle has it is no vehicle to list.
+ if (!complete)
+ return at;
+ // One with its end missing still counts in its trainset: the starter
+ // reads it all the same, and writes it whole for a start. One written
+ // for a trainset with none around it is no loose vehicle either.
+ if (_open is { } current)
+ _open = current with { Vehicles = current.Vehicles + 1 };
+ else if (closed && !TrainsetStyle(at))
+ Loose.Add((tokens[at].Index, tokens[meant].End));
+ return meant;
+ }
+
+ // What is written wrong in a vehicle's fields, the numbers told of right away.
+ private (SceneryFaultKind? Fault, bool Trackless) CheckFields(int at, bool complete, bool inTrainset, string name)
+ {
+ SceneryFaultKind? fault = null;
+ // A track named by a number is none the simulator has: it drops the vehicle
+ // right after its load, going to the next enddynamic.
+ bool trackless = false;
+ if (!complete)
+ fault = SceneryFaultKind.VehicleIncomplete;
+ else
+ {
+ Number(tokens[at + 1], name);
+ Number(tokens[at + 2], name);
+ if (inTrainset)
+ {
+ Number(tokens[at + 8], name);
+ string coupling = tokens[at + 10].Text;
+ int dot = coupling.IndexOf('.');
+ string flags = dot < 0 ? coupling : coupling[..dot];
+ if (flags.Length > 0 && !int.TryParse(flags, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
+ Fault(SceneryFaultKind.NotANumber, tokens[at + 10].Index, name, coupling);
+ }
+ else if (TrainsetStyle(at))
+ {
+ // Its header missing or commented out.
+ fault = SceneryFaultKind.VehicleOutsideTrainset;
+ trackless = true;
+ }
+ else
+ {
+ Number(tokens[at + 9], name);
+ Number(tokens[at + 11], name);
+ trackless = IsNumber(tokens[at + 8]);
+ }
+ }
+ return (fault, trackless);
+ }
+
+ // The last word of a vehicle, from its load count on, with what is written wrong
+ // there unless something before it already was.
+ private int LoadEnd(int next, bool trackless, int meant, ref SceneryFaultKind? fault, out string? faulty)
+ {
+ faulty = null;
+
+ // Then the load: its count, and only after a count other than 0 its type.
+ var count = tokens[next++];
+ if (Counted(count.Text))
+ {
+ if (next < tokens.Count && tokens[next].Is("enddynamic") && fault is null)
+ (fault, faulty) = (SceneryFaultKind.LoadTypeMissing, count.Text);
+ next++;
+ }
+ else if (count.Is("enddynamic"))
+ fault ??= SceneryFaultKind.LoadCountMissing;
+
+ // Then enddynamic - or a destination, one word more, and on to the next enddynamic.
+ int end;
+ if (trackless)
+ end = EndDynamic(next);
+ else if (next < tokens.Count && tokens[next].Is("enddynamic"))
+ end = next;
+ else
+ {
+ end = EndDynamic(next + 2);
+ if (fault is null && end > meant)
+ (fault, faulty) = (SceneryFaultKind.WordBeforeEnd, tokens[next].Text);
+ }
+ return end;
+ }
+
+ private int Include(int at)
+ {
+ var token = tokens[at];
+ string name = at + 1 < tokens.Count ? tokens[at + 1].Text : "";
+
+ if (!asSimulator)
+ {
+ // Its parameters run to "end". Another include, a trainset or a node on
+ // the way means the end is missing - read on as what they are.
+ int stop = at + 2;
+ while (stop < tokens.Count && !tokens[stop].Is("end") && !Keyword(tokens[stop]) &&
+ !tokens[stop].Is("include") && !tokens[stop].Is("firstinit"))
+ stop++;
+ 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 (lookup.OnDisk is { } onDisk)
+ Fault(SceneryFaultKind.IncludeLetterCase, token.Index, onDisk, name);
+ else
+ Fault(SceneryFaultKind.MissingInclude, token.Index, token: name);
+ }
+
+ // The simulator takes its parameters up to the word "end", written just so,
+ // whatever comes before it.
+ int end = at + 2;
+ while (end < tokens.Count && tokens[end].Text != "end")
+ end++;
+ int last = Math.Min(end, tokens.Count - 1);
+ if (Lost(at + 1, last) is not null)
+ Fault(SceneryFaultKind.IncludeNotClosed, token.Index, token: name, swallowed: (at + 1, last));
+ return last;
+ }
+
+ // 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) =>
+ at + 9 < tokens.Count && IsNumber(tokens[at + 8]) && Driver(tokens[at + 9]);
+
+ // The first enddynamic from the given word on, or the last word there is.
+ private int EndDynamic(int from)
+ {
+ int at = from;
+ while (at < tokens.Count && !tokens[at].Is("enddynamic"))
+ at++;
+ return Math.Min(at, tokens.Count - 1);
+ }
+
+ ///
+ /// The entries starting past up to ,
+ /// as they would be named in the text - vehicles by their names - or null
+ /// when none start there.
+ ///
+ private string? Lost(int after, int last)
+ {
+ var entries = new List();
+ for (int at = after + 1; at <= last && at < tokens.Count; at++)
+ {
+ var token = tokens[at];
+ if (token.Is("node") && at + 4 < tokens.Count)
+ entries.Add(tokens[at + 4].Is("dynamic")
+ ? tokens[at + 3].Text
+ : $"{tokens[at + 4].Text} {tokens[at + 3].Text}");
+ else if ((token.Is("trainset") || token.Is("include")) && at + 1 < tokens.Count)
+ entries.Add($"{token.Text} {tokens[at + 1].Text}");
+ else if (token.Is("endtrainset") || token.Is("firstinit"))
+ entries.Add(token.Text);
+ }
+
+ return entries.Count == 0 ? null : Listed(entries);
+ }
+
+ // Whether the words after the two given are the same, so many of them.
+ private bool Same(int first, int second, int count)
+ {
+ for (int i = 1; i <= count; i++)
+ if (second + i >= tokens.Count || !tokens[first + i].Text.Equals(tokens[second + i].Text, StringComparison.OrdinalIgnoreCase))
+ return false;
+ return true;
+ }
+
+ /// Whether the words after the one at are all there and none starts something else.
+ private bool Fields(int at, int count)
+ {
+ if (at + count >= tokens.Count)
+ return false;
+ for (int i = at + 1; i <= at + count; i++)
+ if (Keyword(tokens[i]) || tokens[i].Is("enddynamic"))
+ return false;
+ return true;
+ }
+
+ private void Number(SceneryCode.Token field, string subject)
+ {
+ if (!IsNumber(field))
+ Fault(SceneryFaultKind.NotANumber, field.Index, subject, field.Text);
+ }
+
+ /// The words the simulator takes along with it, past the one given up to the last.
+ private void Fault(SceneryFaultKind kind, int index, string? subject = null, string? token = null,
+ (int After, int Last)? swallowed = null)
+ {
+ // Faults are the simulator's reading's to tell.
+ if (!asSimulator)
+ return;
+ string? lost = null;
+ if (swallowed is var (after, last) && Lost(after, last) is { } entries)
+ {
+ lost = entries;
+ Swallowed.Add((tokens[after + 1].Index, tokens[last].Index, index));
+ }
+ Faults.Add(lines.Fault(kind, index, subject, token) with { Lost = lost });
+ }
+ }
+
+ ///
+ /// How many words a trainset's track name was split into by spaces, judged by
+ /// the two numbers - offset and speed - that follow them; null when it was not.
+ ///
+ private static int? SplitTrack(List tokens, int header)
+ {
+ if (IsNumber(tokens[header + 3]))
+ return null;
+
+ for (int words = 2; words <= 4; words++)
+ {
+ int offset = header + 2 + words;
+ if (offset + 1 >= tokens.Count || Keyword(tokens[offset - 1]) || IsNumber(tokens[offset - 1]))
+ return null;
+ if (IsNumber(tokens[offset]) && IsNumber(tokens[offset + 1]))
+ return words;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Whether a load count reads as other than 0, the way the simulator reads a
+ /// whole number: the digits it starts with, and 0 for one that does not.
+ ///
+ private static bool Counted(string text)
+ {
+ int at = text.Length > 0 && text[0] is '+' or '-' ? 1 : 0;
+ bool any = false;
+ for (; at < text.Length && char.IsAsciiDigit(text[at]); at++)
+ any |= text[at] != '0';
+ return any;
+ }
+
+ private static bool Driver(SceneryCode.Token token) =>
+ token.Is("headdriver") || token.Is("reardriver") || token.Is("passenger") || token.Is("nobody");
+
+ private static bool IsNumber(SceneryCode.Token token) =>
+ double.TryParse(token.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out _);
+
+ private static bool Keyword(SceneryCode.Token token) =>
+ token.Is("node") || token.Is("trainset") || token.Is("endtrainset");
+
+ ///
+ /// An include named outright - not one built from a template's parameters or
+ /// picked at random, which only the simulator can tell.
+ ///
+ private static bool Plain(string name) =>
+ name.Length > 0 && !name.Contains("(p", StringComparison.OrdinalIgnoreCase) &&
+ name.IndexOfAny(new[] { '*', '?' }) < 0;
+}
diff --git a/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs b/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs
new file mode 100644
index 0000000..aaadef6
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Text.RegularExpressions;
+using StarterNG.Classes;
+
+namespace StarterNG.Domain.Sceneries;
+
+///
+/// A trainset written back into its own scenery file. Only the vehicles that
+/// changed are the starter's to rewrite: everything else the author put there -
+/// the header as typed, the description, comments, an assignments block, the other
+/// vehicles as they are spelled - stays as it was, and the rest of the file is not
+/// touched at all.
+///
+public static class SceneryTrainsetText
+{
+ private static readonly Regex LoneLineFeed = new(@"(?
+ /// The trainset as the file should now hold it, in the file's own line ends.
+ /// Ends at endtrainset, as the parser's match of the old one did.
+ ///
+ ///
+ /// What stands between the vehicles stays in its place among those that stay.
+ /// A comment inside a vehicle's own node goes when that node is rewritten or
+ /// the vehicle removed: there is no longer a place for it.
+ ///
+ public static string Compose(Trainset trainset, string newline)
+ {
+ string raw = trainset.RawEntry;
+ var saved = new Trainset(raw);
+
+ // Not read to its end: what stands there is not known, so it all stays.
+ if (!saved.Parsed)
+ return raw;
+
+ var (speed, vehicles, end) = saved.Layout;
+
+ // Line ends of both kinds are left as they are: there is no one kind to give them.
+ bool mixed = raw.Contains("\r\n", StringComparison.Ordinal) && LoneLineFeed.IsMatch(raw);
+ string Own(string text) => mixed ? text : Normalize(text).Replace("\n", newline);
+
+ int first = vehicles.Count > 0 ? vehicles[0].Start : end;
+ string head = raw[..first];
+
+ // The speed is the one header field the depot changes - the ready switch.
+ // Rewritten only when it differs, so "0.0" stays "0.0".
+ if (saved.Velocity != trainset.Velocity && speed is { } field)
+ head = raw[..field.Index] + trainset.Velocity.ToString(CultureInfo.InvariantCulture) + raw[field.End..first];
+
+ string[] was = saved.Vehicles.Select(v => v.ToTrainsetNode()).ToArray();
+ string[] now = trainset.Vehicles.Select(v => v.ToTrainsetNode()).ToArray();
+ string ending = end < raw.Length ? raw[end..] : "endtrainset";
+ if (was.SequenceEqual(now))
+ return Own(head + raw[first..end] + ending);
+
+ string Written(int i) => raw[vehicles[i].Start..vehicles[i].End];
+ string After(int i) => raw[vehicles[i].End..(i + 1 < vehicles.Count ? vehicles[i + 1].Start : end)];
+
+ var sb = new StringBuilder(Own(head));
+
+ void AtLineStart()
+ {
+ if (sb.Length > 0 && sb[^1] != '\n')
+ sb.Append(newline);
+ }
+
+ // One moved here is still written as it was.
+ void Rewrite(int j)
+ {
+ AtLineStart();
+ int written = Array.IndexOf(was, now[j]);
+ sb.Append(written >= 0 ? Own(Written(written)) : now[j].TrimEnd('\n'));
+ }
+
+ int[,] staying = Staying(was, now);
+ for (int i = 0, j = 0; i < was.Length || j < now.Length;)
+ {
+ bool both = i < was.Length && j < now.Length;
+ if (both && was[i] == now[j])
+ {
+ sb.Append(Own(Written(i) + After(i)));
+ i++;
+ j++;
+ }
+ else if (both && staying[i + 1, j + 1] == staying[i, j])
+ {
+ // In the place of the one there before, so what followed that one stays.
+ Rewrite(j++);
+ sb.Append(Own(After(i++)));
+ }
+ else if (j < now.Length && (i == was.Length || staying[i, j + 1] == staying[i, j]))
+ {
+ Rewrite(j++);
+ sb.Append(newline);
+ }
+ else
+ {
+ // Gone, the end of its line with it; what followed it stays.
+ string after = After(i++);
+ if (!string.IsNullOrWhiteSpace(after))
+ {
+ AtLineStart();
+ sb.Append(Own(RestOfLine.Replace(after, "", 1)));
+ }
+ }
+ }
+
+ AtLineStart();
+ return sb.Append(Own(ending)).ToString();
+ }
+
+ ///
+ /// How many vehicles can stay in the order they are in, of those from each place on
+ /// in the old list and in the new one: the longest common run of the two.
+ ///
+ private static int[,] Staying(string[] was, string[] now)
+ {
+ var staying = new int[was.Length + 1, now.Length + 1];
+ for (int i = was.Length - 1; i >= 0; i--)
+ for (int j = now.Length - 1; j >= 0; j--)
+ staying[i, j] = was[i] == now[j]
+ ? staying[i + 1, j + 1] + 1
+ : Math.Max(staying[i + 1, j], staying[i, j + 1]);
+ return staying;
+ }
+
+ ///
+ /// The file with the trainset's old text swapped for ,
+ /// or null when that text cannot be told apart in the file: missing, or found
+ /// more often than the scenery has trainsets written that way.
+ ///
+ public static string? Replace(string fileText, Trainset trainset, IReadOnlyList sceneryTrainsets,
+ string entry)
+ {
+ var twins = sceneryTrainsets.Where(t => t.RawEntry == trainset.RawEntry).ToList();
+ int ordinal = twins.FindIndex(t => ReferenceEquals(t, trainset));
+ if (ordinal < 0 || trainset.RawEntry.Length == 0)
+ return null;
+
+ var found = new List();
+ for (int at = fileText.IndexOf(trainset.RawEntry, StringComparison.Ordinal);
+ at >= 0;
+ at = fileText.IndexOf(trainset.RawEntry, at + trainset.RawEntry.Length, StringComparison.Ordinal))
+ found.Add(at);
+
+ if (found.Count != twins.Count)
+ return null;
+
+ int start = found[ordinal];
+ return fileText[..start] + entry + fileText[(start + trainset.RawEntry.Length)..];
+ }
+
+ ///
+ /// Whether the trainset now differs from what its file holds, compared the way
+ /// both would be written so a different spelling of the same number is no change.
+ ///
+ public static bool IsModified(Trainset trainset) =>
+ trainset.Parsed && Signature(trainset) != SavedSignature(trainset.RawEntry);
+
+ private static readonly Dictionary SavedSignatures = new();
+
+ private static string SavedSignature(string raw)
+ {
+ lock (SavedSignatures)
+ {
+ if (!SavedSignatures.TryGetValue(raw, out string? signature))
+ SavedSignatures[raw] = signature = Signature(new Trainset(raw));
+ return signature;
+ }
+ }
+
+ private static string Signature(Trainset trainset) =>
+ trainset.Velocity.ToString(CultureInfo.InvariantCulture) + "\n" +
+ Dynamic.ToTrainsetNodes(trainset.Vehicles);
+
+ private static string Normalize(string text) => text.Replace("\r\n", "\n");
+}
diff --git a/StarterNG/Domain/Sceneries/SceneryWeather.cs b/StarterNG/Domain/Sceneries/SceneryWeather.cs
index ebe9b78..03c7438 100644
--- a/StarterNG/Domain/Sceneries/SceneryWeather.cs
+++ b/StarterNG/Domain/Sceneries/SceneryWeather.cs
@@ -69,6 +69,23 @@ public sealed class SceneryWeather
Dirty = true;
}
+ /// What was set by hand stays over a file read anew; untouched, the file's own stands.
+ public void KeepChoicesOf(SceneryWeather earlier)
+ {
+ if (!earlier.Changed)
+ return;
+
+ Time = earlier.Time;
+ ScenarioTimeOverride = earlier.ScenarioTimeOverride;
+ Day = earlier.Day;
+ Temperature = earlier.Temperature;
+ FogStart = earlier.FogStart;
+ FogEnd = earlier.FogEnd;
+ Overcast = earlier.Overcast;
+ OvercastRandom = earlier.OvercastRandom;
+ Dirty = earlier.Dirty;
+ }
+
public void SetFogRange(int start, int end, IRandomSource random)
{
FogStart = Math.Clamp(start, FogMin, FogMax);
diff --git a/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
index bf4270d..f77d328 100644
--- a/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
+++ b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
@@ -1,5 +1,5 @@
-using System;
using System.Globalization;
+using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
@@ -8,9 +8,26 @@ namespace StarterNG.Domain.Sceneries;
public static class SceneryWeatherWriter
{
- private static readonly Regex ManagedConfigLine = new(
- @"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ // Whole words, as the simulator splits them: "atmo/lasy.scm" is a path, no atmo.
+ private const string WordStart = @"(?What the written block takes the place of - looked for in the code alone, never in a comment.
+ private static readonly Regex[] Replaced =
+ {
+ ConfigBlock,
+ new($@"(?is){WordStart}time\s+\d[^\r\n]*?{WordStart}endtime{WordEnd}"),
+ new($@"(?is){WordStart}atmo{WordEnd}.*?{WordStart}endatmo{WordEnd}"),
+ new(@"(?im)^[ \t]*movelight\s+\S+"),
+ new($@"(?i){WordStart}scenario\.weather\.temperature\s+\S+"),
+ new($@"(?i){WordStart}scenario\.time\.override\s+\S+")
+ };
public static string Rewrite(string text, SceneryWeather weather, IRandomSource random)
{
@@ -19,12 +36,8 @@ public static class SceneryWeatherWriter
string carried = CarriedConfigLines(text);
string stripped = text;
- stripped = Regex.Replace(stripped, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
- stripped = Regex.Replace(stripped, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
- stripped = Regex.Replace(stripped, @"(?is)\batmo\b.*?\bendatmo\b", " ");
- stripped = Regex.Replace(stripped, @"(?im)^[ \t]*movelight\s+\S+", " ");
- stripped = Regex.Replace(stripped, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
- stripped = Regex.Replace(stripped, @"(?i)scenario\.time\.override\s+\S+", " ");
+ foreach (var pattern in Replaced)
+ stripped = Cut(stripped, pattern);
var inv = CultureInfo.InvariantCulture;
string config =
@@ -46,6 +59,38 @@ public static class SceneryWeatherWriter
}
}
+ ///
+ /// The text without what the pattern finds in its code. A comment inside what
+ /// is taken out stays where it is, line ends and all, for a // to end
+ /// where it did.
+ ///
+ private static string Cut(string text, Regex pattern)
+ {
+ string code = SceneryCode.Mask(text, out _);
+ var sb = new StringBuilder(text.Length);
+ int at = 0;
+
+ foreach (Match found in pattern.Matches(code))
+ {
+ sb.Append(text, at, found.Index - at);
+ string written = text.Substring(found.Index, found.Length);
+ if (written == found.Value)
+ {
+ sb.Append(' ');
+ }
+ else
+ {
+ var comments = new StringBuilder(written);
+ foreach (var word in Enumerable.Reverse(SceneryCode.Tokens(found.Value)))
+ comments.Remove(word.Index, word.Text.Length);
+ sb.Append(comments);
+ }
+ at = found.Index + found.Length;
+ }
+
+ return sb.Append(text, at, text.Length - at).ToString();
+ }
+
private static string DelphiHourMinute(string hhmm)
{
int colon = hhmm.IndexOf(':');
@@ -57,20 +102,17 @@ public static class SceneryWeatherWriter
: "0" + hhmm[colon..];
}
+ /// The settings of every config block but those the starter writes itself.
private static string CarriedConfigLines(string text)
{
- var block = Regex.Match(text, @"(?is)\bconfig\b(.*?)\bendconfig\b");
- if (!block.Success)
- return "";
-
var sb = new StringBuilder();
- foreach (string raw in block.Groups[1].Value.Split('\n'))
- {
- string line = raw.TrimEnd('\r').Trim();
- if (line.Length == 0 || ManagedConfigLine.IsMatch(line))
- continue;
- sb.Append(line).Append("\r\n");
- }
+ foreach (Match block in ConfigBlock.Matches(SceneryCode.Mask(text, out _)))
+ foreach (string raw in ManagedSetting.Replace(block.Groups[1].Value, " ").Split('\n'))
+ {
+ string line = raw.Trim();
+ if (line.Length > 0)
+ sb.Append(line).Append("\r\n");
+ }
return sb.ToString();
}
}
diff --git a/StarterNG/Domain/SimulatorNames.cs b/StarterNG/Domain/SimulatorNames.cs
new file mode 100644
index 0000000..602646f
--- /dev/null
+++ b/StarterNG/Domain/SimulatorNames.cs
@@ -0,0 +1,18 @@
+namespace StarterNG.Domain;
+
+///
+/// File names as the simulator makes them: its parser folds every word it reads
+/// to lower case - ASCII letters only - the files a scenery includes and the
+/// scenario given with -s among them, and opens the file by that name.
+/// Where letter case counts, as on Linux, a file is found only when it is named
+/// so on disk.
+///
+public static class SimulatorNames
+{
+ public static string Lower(string name) =>
+ string.Create(name.Length, name, static (folded, source) =>
+ {
+ for (int i = 0; i < source.Length; i++)
+ folded[i] = source[i] is >= 'A' and <= 'Z' ? (char)(source[i] + ('a' - 'A')) : source[i];
+ });
+}
diff --git a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs
index 788062a..e7d3bac 100644
--- a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs
+++ b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs
@@ -5,7 +5,6 @@ using System.Linq;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
-using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Infrastructure.Sceneries;
@@ -21,58 +20,97 @@ public sealed class SceneryParser
_random = random;
}
- public Scenery Parse(string path, string content)
+ ///
+ /// What the simulator makes of an include, as the include names it; null leaves
+ /// includes unchecked.
+ ///
+ public Scenery Parse(string path, string content, Func? findInclude = null)
{
string original = content;
- var faults = new List();
- var attachments = ParseAttachments(original);
+ // A file put in place of its include has directives of its own, for itself.
+ string own = SceneryIncludes.WithoutInlined(original);
+
+ var attachments = ParseAttachments(own);
var includes = new List();
content = StripOptionalIncludes(content, includes);
- var trainsetEntries = new List();
- content = Regex.Replace(content, @"trainset\b[\s\S]*?\bendtrainset\b",
- match =>
- {
- trainsetEntries.Add(match.Value);
- return $"{{{{{trainsetEntries.Count - 1}}}}}";
- },
- RegexOptions.IgnoreCase);
+ // 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 faults = syntax.Faults.ToList();
+ var inlined = SceneryIncludes.Ranges(content);
+ var trainsets = new List();
var looseVehicles = new List();
var looseNames = new List();
- content = ExtractLooseVehicles(content, looseVehicles, looseNames, faults);
+ var cuts = new List<(int Start, int End, string With)>();
- var scenery = new Scenery(path, content)
+ foreach (var (start, end) in syntax.Trainsets)
{
- Group = MatchDirective(original, "l"),
- Name = MatchDirective(original, "n"),
- Description = MatchAllDirectives(original, "d"),
- ImageName = MatchDirective(original, "i"),
- Archival = MatchDirective(original, "a") != null
+ var trainset = new Trainset(content[start..end]) { SourceFile = SceneryIncludes.FileAt(inlined, start) };
+ if (!trainset.Parsed && !faults.Any(f => f.Offset >= start && f.Offset < end))
+ faults.Add(syntax.Lines.Fault(SceneryFaultKind.TrainsetUnreadable, start, trainset.Name));
+ cuts.Add((start, end, $"{{{{{trainsets.Count}}}}}"));
+ trainsets.Add(trainset);
+ }
+
+ foreach (var (start, end) in syntax.LooseVehicles)
+ {
+ // Included files' loose vehicles - road traffic, mostly - stay where they
+ // are as written, only their names are noted.
+ var vehicle = ReadLooseVehicle(content[start..end]);
+ looseNames.Add(vehicle?.Name ?? LooseName(content[start..end]));
+ if (SceneryIncludes.FileAt(inlined, start) != null)
+ continue;
+
+ if (vehicle is null)
+ {
+ if (!faults.Any(f => f.Offset >= start && f.Offset < end))
+ faults.Add(syntax.Lines.Fault(SceneryFaultKind.VehicleUnreadable, start, LooseName(content[start..end])));
+ continue;
+ }
+
+ looseVehicles.Add(vehicle);
+ cuts.Add((start, end, "\n"));
+ }
+
+ var scenery = new Scenery(path, Cut(content, cuts))
+ {
+ Group = MatchDirective(own, "l"),
+ Name = MatchDirective(own, "n"),
+ Description = MatchAllDirectives(own, "d"),
+ ImageName = MatchDirective(own, "i"),
+ Archival = MatchDirective(own, "a") != null
};
scenery.Attachments.AddRange(attachments);
scenery.Includes.AddRange(includes);
+ scenery.Trainsets.AddRange(trainsets);
scenery.LooseVehicles.AddRange(looseVehicles);
scenery.LooseVehicleNames.AddRange(looseNames);
- scenery.Faults.AddRange(faults);
+ scenery.Faults.AddRange(faults.OrderBy(f => f.Offset));
- ParseWeather(original, scenery.Weather);
+ // Without its comments: what is read here is what the writer later puts down as active.
+ ParseWeather(SceneryCode.Mask(original, out _), scenery.Weather);
scenery.Weather.AcceptAsAuthored();
- foreach (string entry in trainsetEntries)
- {
- var trainset = new Trainset(entry);
- scenery.Trainsets.Add(trainset);
- if (!trainset.Parsed)
- scenery.Faults.Add($"# trainset parse fault: {trainset.Name} ({trainset.Track})");
- }
-
return scenery;
}
+ private static string Cut(string text, List<(int Start, int End, string With)> cuts)
+ {
+ var sb = new System.Text.StringBuilder(text.Length);
+ int at = 0;
+ foreach (var (start, end, with) in cuts.OrderBy(c => c.Start))
+ {
+ sb.Append(text, at, start - at).Append(with);
+ at = end;
+ }
+ return sb.Append(text, at, text.Length - at).ToString();
+ }
+
private static List ParseAttachments(string content)
{
var attachments = new List();
@@ -115,7 +153,7 @@ public sealed class SceneryParser
return Regex.Replace(
content,
- @"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
+ @"include[ \t]+(\S+(?:[ \t]+\S+)*?)[ \t]+end[ \t]*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
m =>
{
string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
@@ -137,65 +175,55 @@ public sealed class SceneryParser
Kind = kind,
Selected = kind == 1
});
- return " ";
+ return Regex.Replace(m.Value, @"[^\r\n]", " ");
},
RegexOptions.IgnoreCase);
}
- private static string ExtractLooseVehicles(string content, List vehicles, List names,
- List faults)
+ private static readonly Regex LooseNode = new(
+ @"(?is)^node\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic\b)\S+)*)\s*enddynamic$");
+
+ private static string LooseName(string node) =>
+ SceneryCode.Tokens(SceneryCode.Mask(node, out _)).ElementAtOrDefault(3).Text ?? "";
+
+ /// A vehicle standing on its own, or null when its node cannot be read.
+ private static Dynamic? ReadLooseVehicle(string node)
{
var inv = CultureInfo.InvariantCulture;
+ var m = LooseNode.Match(SceneryCode.Mask(node, out _));
+ if (!m.Success)
+ return null;
- return Regex.Replace(
- content,
- @"(?is)\bnode\s+(\S+)\s+(\S+)\s+(\S+)\s+dynamic\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)((?:\s+(?!enddynamic)\S+)*)\s*enddynamic\b",
- m =>
+ try
+ {
+ var vehicle = new Dynamic
{
- string name = m.Groups[3].Value;
+ RangeMax = float.Parse(m.Groups[1].Value, inv),
+ RangeMin = float.Parse(m.Groups[2].Value, inv),
+ Name = m.Groups[3].Value,
+ DataFolder = m.Groups[4].Value,
+ SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value),
+ MmdFile = m.Groups[6].Value,
+ PathName = m.Groups[7].Value,
+ Offset = float.Parse(m.Groups[8].Value, inv),
+ DriverType = Dynamic.ParseDriver(m.Groups[9].Value),
+ HasVelocity = true,
+ Velocity = float.Parse(m.Groups[10].Value, inv)
+ };
- if (name.StartsWith("{{", StringComparison.Ordinal))
- return m.Value;
-
- try
- {
- var vehicle = new Dynamic
- {
- RangeMax = float.Parse(m.Groups[1].Value, inv),
- RangeMin = float.Parse(m.Groups[2].Value, inv),
- Name = name,
- DataFolder = m.Groups[4].Value,
- SkinFile = Dynamic.StripSkinExtension(m.Groups[5].Value),
- MmdFile = m.Groups[6].Value,
- PathName = m.Groups[7].Value,
- Offset = float.Parse(m.Groups[8].Value, inv),
- DriverType = m.Groups[9].Value.ToLowerInvariant() switch
- {
- "headdriver" => eDriverType.Headdriver,
- "reardriver" => eDriverType.Reardriver,
- "passenger" => eDriverType.Passenger,
- _ => eDriverType.Nobody
- },
- HasVelocity = true,
- Velocity = float.Parse(m.Groups[10].Value, inv)
- };
-
- var trailing = m.Groups[11].Value
- .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
- .ToList();
- vehicle.ReadTrailing(trailing);
-
- vehicles.Add(vehicle);
- names.Add(vehicle.Name);
- return "\n";
- }
- catch
- {
- faults.Add($"# loose dynamic parse fault: {name}");
- names.Add(name);
- return m.Value;
- }
- });
+ vehicle.ReadTrailing(m.Groups[11].Value
+ .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
+ .ToList());
+ return vehicle;
+ }
+ catch (FormatException)
+ {
+ return null;
+ }
+ catch (OverflowException)
+ {
+ return null;
+ }
}
private void ParseWeather(string content, SceneryWeather weather)
diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
index cbbde38..e83d8f7 100644
--- a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
+++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
@@ -3,10 +3,10 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
-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;
@@ -21,6 +21,9 @@ public sealed class SceneryRepository : ISceneryRepository
private readonly IDiagnosticsLog _log;
private readonly SceneryParser _parser;
+ /// The scenery folders' files and folders, listed once per load of them all.
+ private readonly ConcurrentDictionary<(string, bool), IReadOnlyList> _listings = new();
+
public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
{
_files = files;
@@ -31,6 +34,7 @@ public sealed class SceneryRepository : ISceneryRepository
public IReadOnlyList LoadAll(IProgress? progress = null)
{
+ _listings.Clear();
var files = SceneryFiles().ToList();
var loaded = new ConcurrentBag();
int total = Math.Max(1, files.Count);
@@ -40,7 +44,7 @@ public sealed class SceneryRepository : ISceneryRepository
new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
file =>
{
- var scenery = Load(file);
+ var scenery = LoadOne(file);
if (scenery is not null)
loaded.Add(scenery);
@@ -52,14 +56,44 @@ public sealed class SceneryRepository : ISceneryRepository
}
public Scenery? Load(string path)
+ {
+ _listings.Clear();
+ return LoadOne(path);
+ }
+
+ public IReadOnlyList? ListFiles() =>
+ _files.DirectoryExists(_paths.Scenery) ? SceneryFiles().ToList() : null;
+
+ private Scenery? LoadOne(string path)
{
try
{
if (!_files.FileExists(path))
return null;
- var scenery = _parser.Parse(path, LegacyText.Decode(_files.ReadAllBytes(path)));
- scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt"));
+ // Looked at ahead of the reading: changed meanwhile, it counts as changed.
+ var sources = new Dictionary { [path] = FileStamp.Of(_files, path) };
+
+ // With its own .sbt the scenery's terrain comes from it, and the simulator
+ // passes its terrain includes over without opening them.
+ 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)
+ {
+ string? file = ResolveInclude(name);
+ string full = Path.Combine(_paths.Scenery, file ?? name.Replace('\\', '/'));
+ sources[full] = FileStamp.Of(_files, full);
+ return file is null ? null : (file, LegacyText.Decode(_files.ReadAllBytes(full)));
+ }
+
+ string text = SceneryIncludes.Inline(LegacyText.Decode(_files.ReadAllBytes(path)), ReadInclude);
+ var scenery = _parser.Parse(path, text, FindInclude);
+ scenery.HasCompanionTimetable = binaryTerrain;
+ scenery.Sources = sources;
return scenery;
}
catch (Exception ex)
@@ -69,6 +103,58 @@ public sealed class SceneryRepository : ISceneryRepository
}
}
+ ///
+ /// 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.
+ ///
+ private bool Opens(string name) =>
+ _files.FileExists(Path.Combine(_paths.Scenery, SimulatorNames.Lower(name.Replace('\\', '/'))));
+
+ ///
+ /// An include's file as it is named on disk, from the scenery folder, whatever
+ /// the case of its letters - as Windows, or Wine, finds it: whose consists the
+ /// copy written for a start carries in place of the include, so they come along
+ /// on Linux too.
+ ///
+ 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();
+ 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 Listing(string directory, bool files) =>
+ _listings.GetOrAdd((directory, files), key =>
+ {
+ if (!_files.DirectoryExists(key.Item1))
+ return Array.Empty();
+ return key.Item2 ? _files.GetFiles(key.Item1) : _files.GetDirectories(key.Item1);
+ });
+
private IEnumerable SceneryFiles() =>
_files.GetFiles(_paths.Scenery, "*.scn")
.Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix);
diff --git a/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs b/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs
index 75496d4..d309b47 100644
--- a/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs
+++ b/StarterNG/Infrastructure/Sceneries/TimetableLocator.cs
@@ -1,8 +1,10 @@
using System;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Sceneries;
+using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Infrastructure.Sceneries;
@@ -10,6 +12,10 @@ public sealed class TimetableLocator
{
private const string NoTimetable = "none";
+ // The heading of a timetable: [ Rodzaj i numer pociagu | EIE1531 ]
+ private static readonly Regex NumberLine = new(@"^\s*\[\s*Rodzaj\b[^|\r\n]*\|([^\]\r\n]+)",
+ RegexOptions.Multiline | RegexOptions.IgnoreCase);
+
private readonly IFileSystem _files;
public TimetableLocator(IFileSystem files)
@@ -36,4 +42,22 @@ public sealed class TimetableLocator
return candidates.FirstOrDefault(_files.FileExists);
}
+
+ /// The number the timetable gives its train; null with no timetable or no such heading in it.
+ public string? TrainNumber(Scenery scenery, string? name)
+ {
+ if (Resolve(scenery, name) is not { } path)
+ return null;
+
+ try
+ {
+ var heading = NumberLine.Match(LegacyText.Decode(_files.ReadAllBytes(path)));
+ string number = heading.Groups[1].Value.Trim();
+ return number.Length > 0 ? number : null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
}
diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs
index f4f52fd..18b547e 100644
--- a/StarterNG/Views/Scenarios.axaml.cs
+++ b/StarterNG/Views/Scenarios.axaml.cs
@@ -353,7 +353,7 @@ public partial class Scenarios : UserControl
private void PopulateFaults(Scenery scenery)
{
var lines = new List();
- lines.AddRange(scenery.Faults);
+ lines.Add(StarterNG.Domain.Sceneries.SceneryFaultText.Format(scenery.Faults, 500, key => App.Loc[key]));
var db = AppServices.Current.Library.Vehicles;
foreach (var ts in scenery.Trainsets)