diff --git a/StarterNG.Tests/Fakes/StubRandom.cs b/StarterNG.Tests/Fakes/StubRandom.cs
new file mode 100644
index 0000000..cbb91e4
--- /dev/null
+++ b/StarterNG.Tests/Fakes/StubRandom.cs
@@ -0,0 +1,22 @@
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Tests.Fakes;
+
+///
+/// Randomness pinned to the low end of every range, so weather that varies in
+/// production is deterministic in tests.
+///
+public sealed class StubRandom : IRandomSource
+{
+ private readonly int _fixedValue;
+
+ public StubRandom(int fixedValue = 0)
+ {
+ _fixedValue = fixedValue;
+ }
+
+ public int Next(int minInclusive, int maxExclusive) =>
+ Math.Clamp(minInclusive + _fixedValue, minInclusive, Math.Max(minInclusive, maxExclusive - 1));
+
+ public double NextDouble() => 0.0;
+}
diff --git a/StarterNG.Tests/SceneryExportTests.cs b/StarterNG.Tests/SceneryExportTests.cs
new file mode 100644
index 0000000..4fdbb1c
--- /dev/null
+++ b/StarterNG.Tests/SceneryExportTests.cs
@@ -0,0 +1,146 @@
+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()
+ {
+ 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 = Parser().Parse("scenery/x.scn", scn);
+
+ string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
+
+ Assert.DoesNotContain("{{0}}", exported);
+ Assert.Contains("trainset pociag tor_1", exported);
+ Assert.Contains("endtrainset", exported);
+ }
+
+ [Fact]
+ public void Selected_optional_includes_are_written_back_before_FirstInit()
+ {
+ const string scn = """
+ include zima.inc end //$optional 1, Zima
+ x
+ endoptional
+ FirstInit
+ """;
+ var scenery = Parser().Parse("scenery/x.scn", scn);
+
+ string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
+
+ Assert.Contains("include zima.inc end", exported);
+ Assert.True(exported.IndexOf("include zima.inc", StringComparison.Ordinal) <
+ exported.IndexOf("FirstInit", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void A_deselected_optional_include_stays_out()
+ {
+ const string scn = """
+ include zima.inc end //$optional 1, Zima
+ x
+ endoptional
+ FirstInit
+ """;
+ var scenery = Parser().Parse("scenery/x.scn", scn);
+ scenery.Includes[0].Selected = false;
+
+ string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
+
+ Assert.DoesNotContain("include zima.inc", exported);
+ }
+
+ [Fact]
+ public void A_kind_two_include_is_written_only_without_a_companion_timetable()
+ {
+ const string scn = """
+ include rozklad.inc end //$optional 2, Rozkład
+ x
+ endoptional
+ FirstInit
+ """;
+ var scenery = Parser().Parse("scenery/x.scn", scn);
+
+ scenery.HasCompanionTimetable = false;
+ Assert.Contains("include rozklad.inc",
+ scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
+
+ scenery.HasCompanionTimetable = true;
+ Assert.DoesNotContain("include rozklad.inc",
+ scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
+ }
+
+ [Fact]
+ public void Edited_weather_replaces_the_authored_config_block()
+ {
+ const string scn = """
+ config
+ movelight 100
+ scenario.weather.temperature 5
+ eventlauncher.range 200
+ endconfig
+ atmo 0 0 0 10 2000 0 0 0 0 endatmo
+ time 8:30 0 0 endtime
+ FirstInit
+ """;
+ var scenery = Parser().Parse("scenery/x.scn", scn);
+
+ scenery.Weather.Day = 200;
+ scenery.Weather.Temperature = 25;
+ scenery.Weather.Dirty = true;
+
+ string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
+
+ Assert.Contains("movelight 200", exported);
+ Assert.Contains("scenario.weather.temperature 25", exported);
+ Assert.DoesNotContain("movelight 100", exported);
+ // Config the scenery author wrote and the starter does not manage survives.
+ Assert.Contains("eventlauncher.range 200", exported);
+ }
+
+ [Fact]
+ public void Decor_trainsets_can_be_dropped_from_the_export()
+ {
+ const string scn = """
+ //$n Test
+ trainset ai_1 tor_1 10 0 //$decor
+ node -1 -1 ai-1 dynamic PKP/EP07 ep07 ep07.mmd 0 nobody 0
+ endtrainset
+ FirstInit
+ """;
+ var scenery = Parser().Parse("scenery/x.scn", scn);
+
+ Assert.True(scenery.Trainsets[0].Decor);
+ Assert.DoesNotContain("trainset ai_1",
+ scenery.BuildExportContent(skipDecorTrainsets: true, new StubRandom()));
+ Assert.Contains("trainset ai_1",
+ scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom()));
+ }
+
+ [Fact]
+ public void Loose_vehicles_are_appended_as_nodes_again()
+ {
+ const string scn = """
+ 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);
+
+ string exported = scenery.BuildExportContent(skipDecorTrainsets: false, new StubRandom());
+
+ Assert.Contains("wagon_1", exported);
+ Assert.Contains("enddynamic", exported);
+ }
+}
diff --git a/StarterNG.Tests/SceneryParserTests.cs b/StarterNG.Tests/SceneryParserTests.cs
new file mode 100644
index 0000000..5a5dd84
--- /dev/null
+++ b/StarterNG.Tests/SceneryParserTests.cs
@@ -0,0 +1,154 @@
+using StarterNG.Infrastructure.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()
+ {
+ const string scn = """
+ //$l Śląsk
+ //$n Osobowy do Katowic
+ //$d Pierwszy opis
+ //$d drugi wiersz
+ //$i images/kato.jpg
+ """;
+
+ var scenery = Parser().Parse("scenery/kato.scn", scn);
+
+ Assert.Equal("Śląsk", scenery.Group);
+ Assert.Equal("Osobowy do Katowic", scenery.Name);
+ Assert.Equal("Pierwszy opis\ndrugi wiersz", scenery.Description);
+ Assert.Equal("images/kato.jpg", scenery.ImageName);
+ Assert.False(scenery.Archival);
+ Assert.Equal("kato", scenery.DisplayName);
+ }
+
+ [Fact]
+ public void The_a_directive_marks_a_scenery_archival()
+ {
+ var scenery = Parser().Parse("scenery/old.scn", "//$a\n//$n Stary\n");
+
+ Assert.True(scenery.Archival);
+ }
+
+ [Fact]
+ public void Attachments_take_a_label_from_the_rest_of_the_line()
+ {
+ const string scn = """
+ //$f 01 doc/rozklad.pdf Rozkład jazdy
+ //$f doc/mapa.png Mapa
+ //$f doc/notes.txt
+ """;
+
+ var scenery = Parser().Parse("scenery/x.scn", scn);
+
+ Assert.Collection(scenery.Attachments,
+ first =>
+ {
+ Assert.Equal("doc/rozklad.pdf", first.FilePath);
+ Assert.Equal("Rozkład jazdy", first.Label);
+ },
+ second =>
+ {
+ Assert.Equal("doc/mapa.png", second.FilePath);
+ Assert.Equal("Mapa", second.Label);
+ },
+ third =>
+ {
+ Assert.Equal("doc/notes.txt", third.FilePath);
+ Assert.Equal("notes.txt", third.Label);
+ });
+ }
+
+ [Fact]
+ public void Optional_includes_are_lifted_out_of_the_template()
+ {
+ const string scn = """
+ include zima.inc end //$optional 1, Sceneria zimowa
+ some content
+ endoptional
+ FirstInit
+ """;
+
+ var scenery = Parser().Parse("scenery/x.scn", scn);
+
+ var include = Assert.Single(scenery.Includes);
+ Assert.Equal("zima.inc", include.FilePath);
+ Assert.Equal("Sceneria zimowa", include.Desc);
+ Assert.Equal(1, include.Kind);
+ Assert.True(include.Selected);
+ Assert.DoesNotContain("zima.inc", scenery.Template);
+ }
+
+ [Fact]
+ public void Includes_of_kind_two_are_off_until_export_decides()
+ {
+ const string scn = """
+ include rozklad.inc end //$optional 2, Rozkład wbudowany
+ x
+ endoptional
+ """;
+
+ var include = Assert.Single(Parser().Parse("scenery/x.scn", scn).Includes);
+
+ Assert.Equal(2, include.Kind);
+ Assert.False(include.Selected);
+ }
+
+ [Fact]
+ public void Trainsets_are_parsed_and_replaced_by_placeholders()
+ {
+ 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 = Parser().Parse("scenery/x.scn", scn);
+
+ var trainset = Assert.Single(scenery.Trainsets);
+ Assert.True(trainset.Parsed);
+ Assert.Equal("pociag", trainset.Name);
+ Assert.Equal("tor_1", trainset.Track);
+ Assert.Contains("{{0}}", scenery.Template);
+ Assert.DoesNotContain("endtrainset", scenery.Template);
+ }
+
+ [Fact]
+ 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
+ FirstInit
+ """;
+
+ var scenery = Parser().Parse("scenery/x.scn", scn);
+
+ var vehicle = Assert.Single(scenery.LooseVehicles);
+ Assert.Equal("wagon_1", vehicle.Name);
+ Assert.Equal("PKP/111A", vehicle.DataFolder);
+ Assert.Equal(new[] { "wagon_1" }, scenery.LooseVehicleNames);
+ Assert.DoesNotContain("enddynamic", scenery.Template);
+ }
+
+ [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";
+
+ var scenery = 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"));
+ Assert.Contains("enddynamic", scenery.Template);
+ }
+}
diff --git a/StarterNG.Tests/SceneryRepositoryTests.cs b/StarterNG.Tests/SceneryRepositoryTests.cs
new file mode 100644
index 0000000..facaa7e
--- /dev/null
+++ b/StarterNG.Tests/SceneryRepositoryTests.cs
@@ -0,0 +1,101 @@
+using System.Text;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Sceneries;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class SceneryRepositoryTests
+{
+ private static SceneryRepository Build(InMemoryFileSystem files, out FileDiagnosticsLog log)
+ {
+ var paths = new GamePaths("game");
+ var clock = new FixedClock(new DateTime(2026, 1, 1));
+ log = new FileDiagnosticsLog(files, clock, paths);
+ return new SceneryRepository(files, paths, log, new SceneryParser(clock, new StubRandom()));
+ }
+
+ [Fact]
+ public void Every_scn_in_the_scenery_folder_is_loaded()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice")
+ .WithFile(Path.Combine("game", "scenery", "gdansk.scn"), "//$n Gdańsk");
+
+ var loaded = Build(files, out _).LoadAll();
+
+ Assert.Equal(2, loaded.Count);
+ Assert.Contains(loaded, scenery => scenery.Name == "Katowice");
+ }
+
+ [Fact]
+ public void Authoring_scratch_files_and_other_extensions_are_skipped()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(Path.Combine("game", "scenery", "$wip.scn"), "//$n Robocza")
+ .WithFile(Path.Combine("game", "scenery", "kato.inc"), "//$n Include")
+ .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice");
+
+ var loaded = Build(files, out _).LoadAll();
+
+ Assert.Equal("Katowice", Assert.Single(loaded).Name);
+ }
+
+ [Fact]
+ public void A_companion_sbt_is_noticed_when_the_scenery_is_loaded()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice")
+ .WithFile(Path.Combine("game", "scenery", "kato.sbt"), "timetable");
+
+ var scenery = Assert.Single(Build(files, out _).LoadAll());
+
+ Assert.True(scenery.HasCompanionTimetable);
+ }
+
+ [Fact]
+ public void A_missing_scenery_loads_as_null_rather_than_throwing()
+ {
+ Assert.Null(Build(new InMemoryFileSystem(), out _).Load(Path.Combine("game", "scenery", "gone.scn")));
+ }
+
+ [Fact]
+ public void Progress_is_reported_once_per_file()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(Path.Combine("game", "scenery", "a.scn"), "//$n A")
+ .WithFile(Path.Combine("game", "scenery", "b.scn"), "//$n B");
+ var steps = new List();
+
+ Build(files, out _).LoadAll(new Progress(
+ step => { lock (steps) steps.Add(step.Loaded); }));
+
+ // Progress posts asynchronously; give it a moment to drain.
+ SpinWait.SpinUntil(() => { lock (steps) return steps.Count == 2; }, TimeSpan.FromSeconds(2));
+ lock (steps)
+ Assert.Equal(new[] { 1, 2 }, steps.OrderBy(value => value));
+ }
+
+ [Fact]
+ public void The_preview_image_is_found_next_to_the_scenery()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$i kato.jpg")
+ .WithFile(Path.Combine("game", "scenery", "images", "kato.jpg"), "binary");
+ var scenery = Assert.Single(Build(files, out _).LoadAll());
+
+ string? image = new SceneryImageLocator(files).Resolve(scenery);
+
+ Assert.Equal(Path.Combine("game", "scenery", "images", "kato.jpg"), image);
+ }
+
+ [Fact]
+ public void A_scenery_without_an_image_directive_resolves_to_nothing()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(Path.Combine("game", "scenery", "kato.scn"), "//$n Katowice");
+ var scenery = Assert.Single(Build(files, out _).LoadAll());
+
+ Assert.Null(new SceneryImageLocator(files).Resolve(scenery));
+ }
+}
diff --git a/StarterNG.Tests/SceneryWeatherTests.cs b/StarterNG.Tests/SceneryWeatherTests.cs
new file mode 100644
index 0000000..4e968c4
--- /dev/null
+++ b/StarterNG.Tests/SceneryWeatherTests.cs
@@ -0,0 +1,87 @@
+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()
+ {
+ const string scn = """
+ config
+ movelight 172
+ scenario.weather.temperature 21.5
+ scenario.time.override 6:05
+ endconfig
+ atmo 0 0 0 50 900 0 0 0 0.7 endatmo
+ time 8:30 0 0 endtime
+ """;
+
+ var weather = Parser(new DateTime(2026, 1, 1)).Parse("scenery/x.scn", scn).Weather;
+
+ Assert.True(weather.IsAuthored);
+ Assert.Equal(172, weather.Day);
+ Assert.Equal(21.5, weather.Temperature);
+ Assert.Equal("06:05", weather.ScenarioTimeOverride);
+ Assert.Equal("08:30", weather.Time);
+ Assert.Equal(50, weather.FogStart);
+ Assert.Equal(0.7, weather.Overcast);
+ }
+
+ [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;
+
+ Assert.Equal("17:42", weather.ScenarioTimeOverride);
+ Assert.False(weather.IsAuthored);
+ }
+
+ [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;
+
+ Assert.False(weather.Changed);
+ }
+
+ [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;
+
+ weather.Day = 5;
+ weather.Temperature = -12;
+ Assert.True(weather.Changed);
+
+ weather.Restore();
+
+ Assert.False(weather.Changed);
+ Assert.Equal(100, weather.Day);
+ Assert.True(weather.Dirty);
+ }
+
+ [Fact]
+ public void A_fog_range_is_clamped_into_the_supported_span()
+ {
+ var weather = new SceneryWeather();
+
+ weather.SetFogRange(start: 1, end: 999999, random: new StubRandom());
+
+ Assert.Equal(SceneryWeather.FogMin, weather.FogStart);
+ Assert.InRange(weather.FogEnd, SceneryWeather.FogMin, SceneryWeather.FogMax);
+ }
+
+ [Fact]
+ public void Random_overcast_draws_from_the_presets_rather_than_the_stored_value()
+ {
+ var weather = new SceneryWeather { Overcast = 0.42, OvercastRandom = true };
+
+ Assert.Equal(-1.5, weather.EffectiveOvercast(new StubRandom()));
+ Assert.Equal(0.42, new SceneryWeather { Overcast = 0.42 }.EffectiveOvercast(new StubRandom()));
+ }
+}
diff --git a/StarterNG/Application/Abstractions/IRandomSource.cs b/StarterNG/Application/Abstractions/IRandomSource.cs
new file mode 100644
index 0000000..f5f57b2
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IRandomSource.cs
@@ -0,0 +1,15 @@
+namespace StarterNG.Application.Abstractions;
+
+///
+/// Port over randomness. Scenery weather deliberately varies between runs (fog
+/// distance, overcast presets, texture randomiser); routing it through a port
+/// keeps those rules assertable instead of flaky.
+///
+public interface IRandomSource
+{
+ /// Inclusive lower bound, exclusive upper bound.
+ int Next(int minInclusive, int maxExclusive);
+
+ /// Uniform value in [0, 1).
+ double NextDouble();
+}
diff --git a/StarterNG/Application/Abstractions/ISceneryRepository.cs b/StarterNG/Application/Abstractions/ISceneryRepository.cs
new file mode 100644
index 0000000..f923fc7
--- /dev/null
+++ b/StarterNG/Application/Abstractions/ISceneryRepository.cs
@@ -0,0 +1,24 @@
+using System;
+using System.Collections.Generic;
+using StarterNG.Domain.Sceneries;
+
+namespace StarterNG.Application.Abstractions;
+
+/// How far a scenery sweep has got, for the splash screen.
+public readonly record struct SceneryLoadProgress(int Loaded, int Total, string FileName);
+
+///
+/// Reads the scenarios of an installation. The only route from a .scn on disk to
+/// a ; nothing else constructs one from a path.
+///
+public interface ISceneryRepository
+{
+ ///
+ /// Every scenario in the installation, in no particular order. Files that
+ /// fail to parse are logged and skipped rather than failing the load.
+ ///
+ IReadOnlyList LoadAll(IProgress? progress = null);
+
+ /// Reloads a single scenario; null when it is gone or unreadable.
+ Scenery? Load(string path);
+}
diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs
index 5540e6c..742aef5 100644
--- a/StarterNG/Application/AppServices.cs
+++ b/StarterNG/Application/AppServices.cs
@@ -1,6 +1,7 @@
using System;
using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Sceneries;
using StarterNG.Services;
namespace StarterNG.Application;
@@ -33,8 +34,11 @@ public sealed class AppServices
var log = new FileDiagnosticsLog(files, clock, paths);
Log = log;
+ Random = new SystemRandomSource();
Processes = new SystemProcessLauncher(log);
Localization = new LocalizationService(log);
+ Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
+ SceneryImages = new SceneryImageLocator(files);
}
///
@@ -66,6 +70,12 @@ public sealed class AppServices
public IProcessLauncher Processes { get; }
+ public IRandomSource Random { get; }
+
+ public ISceneryRepository Sceneries { get; }
+
+ public SceneryImageLocator SceneryImages { get; }
+
///
/// Exposed as the concrete service rather than
/// because the XAML binds to it as a change-notifying source; code should take
diff --git a/StarterNG/Classes/AppState.cs b/StarterNG/Classes/AppState.cs
index 3b9bbec..d0f0845 100644
--- a/StarterNG/Classes/AppState.cs
+++ b/StarterNG/Classes/AppState.cs
@@ -1,4 +1,5 @@
-using System;
+using System;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes;
diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs
index 348d8e5..938f57e 100644
--- a/StarterNG/Classes/GameData.cs
+++ b/StarterNG/Classes/GameData.cs
@@ -4,6 +4,9 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using StarterNG.Application;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes;
@@ -26,17 +29,12 @@ public sealed class GameData
public bool Loaded { get; private set; }
public void Load(IProgress? progress = null,
- string sceneryDir = "scenery/",
string miniDir = "textures/mini/",
string dynamicRoot = "dynamic")
{
if (Loaded)
return;
- var scnFiles = EnumerateScenery(sceneryDir);
- int total = Math.Max(1, scnFiles.Count);
- int done = 0;
-
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
int vehicles = Vehicles.LoadFromTexturesTxt(dynamicRoot);
if (vehicles == 0)
@@ -46,61 +44,19 @@ public sealed class GameData
() => VehicleDatabase.PreloadMiniIndex(miniDir),
() => Physics.PreloadIndex());
- var sceneryLock = new object();
- var doneCount = done;
- Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
- file =>
- {
- try
- {
- var scenery = new Scenery(file);
- lock (sceneryLock)
- {
- Sceneries.Add(scenery);
- doneCount++;
- progress?.Report(new LoadStatus((double)doneCount / total, LoadPhase.Sceneries,
- Path.GetFileName(file)));
- }
- }
- catch (Exception ex)
- {
- StarterNG.Infrastructure.Diagnostics.Log($"scenery/{Path.GetFileName(file)}", ex);
- }
- });
- done = doneCount;
+ var sceneryProgress = progress is null
+ ? null
+ : new Progress(step => progress.Report(
+ new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName)));
+ ReloadSceneries(sceneryProgress);
progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
Loaded = true;
}
- public void ReloadSceneries(string sceneryDir = "scenery/")
+ public void ReloadSceneries(IProgress? progress = null)
{
Sceneries.Clear();
- var scnFiles = EnumerateScenery(sceneryDir);
- var sceneryLock = new object();
- Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
- file =>
- {
- try
- {
- var scenery = new Scenery(file);
- lock (sceneryLock)
- Sceneries.Add(scenery);
- }
- catch (Exception ex)
- {
- StarterNG.Infrastructure.Diagnostics.Log($"scenery/{Path.GetFileName(file)}", ex);
- }
- });
- }
-
- private static List EnumerateScenery(string sceneryDir)
- {
- if (!Directory.Exists(sceneryDir))
- return new List();
-
- return Directory.GetFiles(sceneryDir, "*.scn")
- .Where(p => !Path.GetFileName(p).StartsWith("$"))
- .ToList();
+ Sceneries.AddRange(AppServices.Current.Sceneries.LoadAll(progress));
}
}
diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs
deleted file mode 100644
index 2743994..0000000
--- a/StarterNG/Classes/Scenery.cs
+++ /dev/null
@@ -1,541 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Text.RegularExpressions;
-using StarterNG.Domain;
-
-namespace StarterNG.Classes;
-
-public sealed class SceneryAttachment
-{
- public string FilePath = "";
- public string Label = "";
-}
-
-public sealed class SceneryInclude
-{
- public string FilePath = "";
- public string Desc = "";
- public int Kind;
- public bool Selected;
-}
-
-public class Scenery
-{
- public List Lines;
- public List Trainsets;
- public List Attachments = new();
- public List Includes = new();
-
- public List LooseVehicleNames = new();
-
- public List LooseVehicles = new();
-
- public List Faults = new();
- public string Group;
- public string Path;
-
- public string Name;
- public string Description;
- public string ImageName;
-
- public bool Archival;
-
- public string Time = "10:30";
- public string ScenarioTimeOverride = "10:30";
- public int Day = 0;
- public double Temperature = 15;
- public int FogStart = 10;
- public int FogEnd = 2000;
-
- public const int FogMin = 10;
- public const int FogMax = 10000;
- public double Overcast = 0;
- public bool OvercastRandom;
-
- public bool HasWeather;
-
- public bool WeatherDirty;
-
- private string _origTime = "10:30";
- private string _origOverride = "10:30";
- private int _origDay;
- private double _origTemperature = 15;
- private int _origFogEnd = 2000;
- private double _origOvercast;
- private bool _origOvercastRandom;
-
- private readonly string _template;
-
- public string DisplayName
- {
- get
- {
- return System.IO.Path.GetFileNameWithoutExtension(Path);
- }
- }
-
- public string LocalizedDescription =>
- string.IsNullOrEmpty(Description) ? "" :
- string.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
-
- private string _imagePath;
- private bool _imagePathResolved;
-
- public Scenery(string path)
- {
- this.Path = path;
- Trainsets = new List();
- if (!File.Exists(path))
- throw new FileNotFoundException(path);
- var encoding = Encoding.GetEncoding(1250);
- string content = File.ReadAllText(path, encoding);
-
- this.Group = MatchDirective(content, "l");
- this.Name = MatchDirective(content, "n");
-
- this.Description = MatchAllDirectives(content, "d");
- this.ImageName = MatchDirective(content, "i");
-
- this.Archival = MatchDirective(content, "a") != null;
-
- ParseWeather(content);
- SnapshotWeather();
-
- ParseAttachments(content);
- content = ParseAndStripOptionalIncludes(content);
-
- List trainsetEntries = new List();
- Regex regex = new Regex(
- @"trainset\b[\s\S]*?\bendtrainset\b",
- RegexOptions.IgnoreCase
- );
- int idx = 0;
- content = regex.Replace(content, match =>
- {
- trainsetEntries.Add(match.Value);
- return $"{{{{{idx++}}}}}";
- });
- content = ExtractLooseVehicles(content);
- _template = content;
-
- foreach (string trainsetEntry in trainsetEntries)
- {
- var ts = new Trainset(trainsetEntry);
- Trainsets.Add(ts);
- if (!ts.Parsed)
- Faults.Add($"# trainset parse fault: {ts.Name} ({ts.Track})");
- }
- }
-
- private string ExtractLooseVehicles(string content)
- {
- LooseVehicles.Clear();
- LooseVehicleNames.Clear();
- var inv = System.Globalization.CultureInfo.InvariantCulture;
-
- 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 =>
- {
- string name = m.Groups[3].Value;
- if (name.StartsWith("{{", StringComparison.Ordinal))
- return m.Value;
- try
- {
- var d = 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();
- d.ReadTrailing(trailing);
- LooseVehicles.Add(d);
- LooseVehicleNames.Add(d.Name);
- return "\n";
- }
- catch
- {
- Faults.Add($"# loose dynamic parse fault: {name}");
- LooseVehicleNames.Add(name);
- return m.Value;
- }
- });
- }
-
- public string BuildExportContent(bool skipDecorTrainsets = false)
- {
- string result = _template;
-
- if (WeatherDirty)
- result = RewriteWeather(result);
-
- result = InjectIncludes(result);
-
- for (int i = 0; i < Trainsets.Count; i++)
- {
- string entry = skipDecorTrainsets && Trainsets[i].Decor
- ? string.Empty
- : Trainsets[i].ToSceneryEntry();
- result = result.Replace("{{" + i + "}}", entry);
- }
-
- if (LooseVehicles.Count > 0)
- result = AppendLooseVehicles(result);
-
- return result;
- }
-
- private string AppendLooseVehicles(string text)
- {
- var sb = new StringBuilder();
- foreach (var v in LooseVehicles)
- sb.Append(v.ToLooseNode());
- if (sb.Length == 0) return text;
-
- var fi = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase);
- if (fi.Success)
- return text[..fi.Index] + sb + text[fi.Index..];
- return text + sb;
- }
-
- private void ParseAttachments(string content)
- {
- Attachments.Clear();
- foreach (Match m in Regex.Matches(content, @"^//\$f\b[ \t]*([^\r\n]*)", RegexOptions.Multiline))
- {
- string rest = m.Groups[1].Value.Trim();
- if (rest.Length == 0) continue;
- string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
-
- if (parts.Length >= 3 && parts[0].Length <= 2)
- Attachments.Add(new SceneryAttachment
- {
- FilePath = parts[1],
- Label = string.Join(" ", parts.Skip(2))
- });
- else if (parts.Length >= 2)
- Attachments.Add(new SceneryAttachment
- {
- FilePath = parts[0],
- Label = string.Join(" ", parts.Skip(1))
- });
- else
- Attachments.Add(new SceneryAttachment
- {
- FilePath = parts[0],
- Label = System.IO.Path.GetFileName(parts[0])
- });
- }
- }
-
- private string ParseAndStripOptionalIncludes(string content)
- {
- Includes.Clear();
-
- if (content.IndexOf("$optional", StringComparison.OrdinalIgnoreCase) < 0)
- return content;
-
- return Regex.Replace(
- content,
- @"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
- m =>
- {
- string incPath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
- string raw = m.Groups[2].Value.Trim().TrimStart(',', ' ');
- string[] par = raw.Split(new[] { ',', '|', ';' },
- StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
-
- int kind = 1;
- string desc = incPath;
- if (par.Length >= 1 && int.TryParse(par[0], out int k))
- kind = k;
- if (par.Length >= 2)
- desc = par[1];
-
- Includes.Add(new SceneryInclude
- {
- FilePath = incPath,
- Desc = desc,
- Kind = kind,
- Selected = kind == 1
- });
- return " ";
- },
- RegexOptions.IgnoreCase);
- }
-
- private string InjectIncludes(string text)
- {
- if (Includes.Count == 0)
- return text;
-
- var sb = new StringBuilder();
- string sbt = System.IO.Path.ChangeExtension(Path, ".sbt");
- bool hasSbt = File.Exists(sbt);
-
- foreach (var inc in Includes)
- {
- if (inc.Kind == 2)
- {
- if (!hasSbt)
- sb.Append("include ").Append(inc.FilePath).Append(" end\r\n");
- continue;
- }
- if (inc.Selected)
- sb.Append("include ").Append(inc.FilePath).Append(" end\r\n");
- }
-
- if (sb.Length == 0)
- return text;
-
- var fi = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase);
- if (fi.Success)
- return text[..fi.Index] + sb + text[fi.Index..];
- return sb + text;
- }
-
- private void ParseWeather(string content)
- {
-
- var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
- if (time.Success)
- {
- Time = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}";
- ScenarioTimeOverride = Time;
- }
-
- var ovr = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})");
- if (ovr.Success)
- {
- ScenarioTimeOverride = $"{ovr.Groups[1].Value.PadLeft(2, '0')}:{ovr.Groups[2].Value}";
- HasWeather = true;
- }
- else if (!time.Success)
- {
-
- var now = System.DateTime.Now;
- ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}";
- }
-
- var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)");
- if (move.Success && int.TryParse(move.Groups[1].Value, out int day))
- {
- Day = day;
- HasWeather = true;
- }
-
- var temp = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)");
- if (temp.Success &&
- double.TryParse(temp.Groups[1].Value, System.Globalization.NumberStyles.Float,
- System.Globalization.CultureInfo.InvariantCulture, out double t))
- {
- Temperature = t;
- HasWeather = true;
- }
-
- var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b");
- if (atmo.Success)
- {
- var nums = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?")
- .Select(m => m.Value).ToList();
- if (nums.Count >= 5 &&
- int.TryParse(nums[3], out int fogStart) &&
- int.TryParse(nums[4], out int fogEnd))
- {
- FogStart = Math.Clamp(fogStart, FogMin, FogMax);
- fogEnd = Math.Clamp(fogEnd, FogStart, FogMax);
- FogEnd = fogEnd > FogStart
- ? Random.Shared.Next(FogStart, fogEnd + 1)
- : fogEnd;
- HasWeather = true;
- }
- if (nums.Count >= 6 &&
- double.TryParse(nums[^1], System.Globalization.NumberStyles.Float,
- System.Globalization.CultureInfo.InvariantCulture, out double oc))
- Overcast = oc;
- }
- }
-
- private void SnapshotWeather()
- {
- _origTime = Time;
- _origOverride = ScenarioTimeOverride;
- _origDay = Day;
- _origTemperature = Temperature;
- _origFogEnd = FogEnd;
- _origOvercast = Overcast;
- _origOvercastRandom = OvercastRandom;
- }
-
- public bool WeatherChanged =>
- Day != _origDay ||
- ScenarioTimeOverride != _origOverride ||
- Math.Abs(Temperature - _origTemperature) > 0.001 ||
- FogEnd != _origFogEnd ||
- OvercastRandom != _origOvercastRandom ||
- (!OvercastRandom && Math.Abs(Overcast - _origOvercast) > 0.001);
-
- public void RestoreWeather()
- {
- Time = _origTime;
- ScenarioTimeOverride = _origOverride;
- Day = _origDay;
- Temperature = _origTemperature;
- FogEnd = _origFogEnd;
- Overcast = _origOvercast;
- OvercastRandom = _origOvercastRandom;
- WeatherDirty = true;
- }
-
- private static readonly double[] OvercastPresets = { -1.5, 0, 0.3, 0.7, 1, 1.1, 1.5 };
-
- private double EffectiveOvercast() =>
- OvercastRandom ? OvercastPresets[Random.Shared.Next(OvercastPresets.Length)] : Overcast;
-
- private string RewriteWeather(string text)
- {
- try
- {
-
- string s = text;
-
- string carried = CarriedConfigLines(s);
-
- s = Regex.Replace(s, @"(?is)\bconfig\b.*?\bendconfig\b", " ");
- s = Regex.Replace(s, @"(?is)\btime\b\s+\d[^\r\n]*?\bendtime\b", " ");
- s = Regex.Replace(s, @"(?is)\batmo\b.*?\bendatmo\b", " ");
- s = Regex.Replace(s, @"(?im)^[ \t]*movelight\s+\S+", " ");
- s = Regex.Replace(s, @"(?i)scenario\.weather\.temperature\s+\S+", " ");
- s = Regex.Replace(s, @"(?i)scenario\.time\.override\s+\S+", " ");
-
- var inv = System.Globalization.CultureInfo.InvariantCulture;
-
- string config =
- "config\r\n" +
- $"movelight {Day}\r\n" +
- $"scenario.weather.temperature {Temperature.ToString(inv)}\r\n" +
- $"scenario.time.override {DelphiHourMinute(ScenarioTimeOverride)}\r\n" +
- carried +
- "endconfig\r\n" +
- $"atmo 0 0 0 {FogEnd} {FogEnd} 0 0 0 {EffectiveOvercast().ToString(inv)} endatmo\r\n" +
- $"time {DelphiHourMinute(Time)} 0 0 endtime\r\n";
-
- return config + s;
- }
- catch
- {
- return text;
- }
- }
-
- private static string DelphiHourMinute(string hhmm)
- {
- int colon = hhmm.IndexOf(':');
- if (colon <= 0) return hhmm;
- return hhmm[..colon].TrimStart('0') is { Length: > 0 } h
- ? h + hhmm[colon..]
- : "0" + hhmm[colon..];
- }
-
- private static readonly Regex ManagedConfigLine = new(
- @"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
- RegexOptions.IgnoreCase | RegexOptions.Compiled);
-
- 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");
- }
- return sb.ToString();
- }
-
- private static string MatchAllDirectives(string content, string letter)
- {
- var matches = Regex.Matches(
- content,
- @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)",
- RegexOptions.Multiline
- );
- if (matches.Count == 0)
- return null;
- return string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd()));
- }
-
- private static string MatchDirective(string content, string letter)
- {
- var match = Regex.Match(
- content,
- @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)",
- RegexOptions.Multiline
- );
- return match.Success ? match.Groups[1].Value.Trim() : null;
- }
-
- public string ImagePath
- {
- get
- {
- if (_imagePathResolved)
- return _imagePath;
- _imagePathResolved = true;
- _imagePath = ResolveImagePath();
- return _imagePath;
- }
- }
-
- private string ResolveImagePath()
- {
- if (string.IsNullOrWhiteSpace(ImageName))
- return null;
-
- string name = ImageName.Replace('\\', '/').Trim();
- string scnDir = System.IO.Path.GetDirectoryName(Path) ?? ".";
- string root = System.IO.Path.GetDirectoryName(scnDir) ?? ".";
-
- var candidates = new List
- {
- name,
- System.IO.Path.Combine(root, name),
- System.IO.Path.Combine(scnDir, name),
- System.IO.Path.Combine(scnDir, "images", name),
- System.IO.Path.Combine(root, "scenery", "images", name),
- };
-
- foreach (string candidate in candidates)
- {
- if (File.Exists(candidate))
- return candidate;
- }
- return null;
- }
-}
diff --git a/StarterNG/Domain/Consist.cs b/StarterNG/Domain/Consist.cs
index b133614..179dfc8 100644
--- a/StarterNG/Domain/Consist.cs
+++ b/StarterNG/Domain/Consist.cs
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using StarterNG.Classes;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Domain;
diff --git a/StarterNG/Domain/Sceneries/Scenery.cs b/StarterNG/Domain/Sceneries/Scenery.cs
new file mode 100644
index 0000000..d340b6c
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/Scenery.cs
@@ -0,0 +1,183 @@
+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;
+
+/// A file the scenery offers to open alongside it (map, manual, ...).
+public sealed class SceneryAttachment
+{
+ public string FilePath = "";
+ public string Label = "";
+}
+
+///
+/// An include the scenery marks as optional, so the user can decide
+/// whether it ends up in the exported scenario.
+///
+public sealed class SceneryInclude
+{
+ public string FilePath = "";
+ public string Desc = "";
+
+ /// 1 = on by default, 2 = only when no companion timetable exists.
+ public int Kind;
+
+ public bool Selected;
+}
+
+///
+/// A scenario the user can start: its consists, loose vehicles, optional
+/// includes and weather, plus the ability to render all of that back into a .scn
+/// the simulator can load.
+///
+///
+/// The aggregate is pure: it never reads or writes files. Parsing lives in
+/// Infrastructure.Sceneries.SceneryParser and loading in
+/// SceneryRepository, which is what lets the export rules be tested from
+/// a string rather than an installation.
+///
+/// is the .scn text with every trainset replaced by a
+/// {{index}} placeholder and the optional includes stripped, so
+/// only has to substitute rather than re-parse.
+///
+public sealed class Scenery
+{
+ public Scenery(string path, string template)
+ {
+ Path = path;
+ Template = template;
+ }
+
+ /// Path of the .scn this was parsed from; the aggregate's identity.
+ public string Path { get; }
+
+ /// The .scn body with trainset placeholders, used for export.
+ public string Template { get; }
+
+ /// True when a companion .sbt timetable sits next to the .scn.
+ public bool HasCompanionTimetable { get; set; }
+
+ public string? Group { get; set; }
+
+ public string? Name { get; set; }
+
+ public string? Description { get; set; }
+
+ public string? ImageName { get; set; }
+
+ /// Scenery kept for historical interest; hidden unless asked for.
+ public bool Archival { get; set; }
+
+ public List Trainsets { get; } = new();
+
+ public List Attachments { get; } = new();
+
+ public List Includes { get; } = new();
+
+ /// Vehicles placed directly as nodes rather than inside a trainset.
+ public List LooseVehicles { get; } = new();
+
+ ///
+ /// Names of every loose vehicle, including those whose node failed to parse
+ /// and therefore has no .
+ ///
+ public List LooseVehicleNames { get; } = new();
+
+ /// Parse problems worth showing on the scenario's fault list.
+ public List Faults { get; } = new();
+
+ public SceneryWeather Weather { get; } = new();
+
+ public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path);
+
+ public string LocalizedDescription =>
+ string.IsNullOrEmpty(Description)
+ ? ""
+ : string.Join("\n", Description.Split('\n').Select(SceneryI18n.T));
+
+ ///
+ /// Renders the scenario the simulator should load: the original file with the
+ /// user's consists, weather and optional includes applied.
+ ///
+ ///
+ /// Drops AI trainsets the user marked irrelevant, which shortens loading.
+ ///
+ public string BuildExportContent(bool skipDecorTrainsets, IRandomSource random)
+ {
+ string result = Template;
+
+ if (Weather.Dirty)
+ result = SceneryWeatherWriter.Rewrite(result, Weather, random);
+
+ result = InjectIncludes(result);
+
+ for (int i = 0; i < Trainsets.Count; i++)
+ {
+ string entry = skipDecorTrainsets && Trainsets[i].Decor
+ ? string.Empty
+ : Trainsets[i].ToSceneryEntry();
+ result = result.Replace("{{" + i + "}}", entry);
+ }
+
+ if (LooseVehicles.Count > 0)
+ result = AppendLooseVehicles(result);
+
+ return result;
+ }
+
+ private string AppendLooseVehicles(string text)
+ {
+ var sb = new StringBuilder();
+ foreach (var vehicle in LooseVehicles)
+ sb.Append(vehicle.ToLooseNode());
+ if (sb.Length == 0)
+ return text;
+
+ return InsertBeforeFirstInit(text, sb.ToString(), fallbackAtEnd: true);
+ }
+
+ private string InjectIncludes(string text)
+ {
+ if (Includes.Count == 0)
+ return text;
+
+ var sb = new StringBuilder();
+ foreach (var include in Includes)
+ {
+ // Kind 2 is the scenery's own timetable stand-in: it is only wanted
+ // when the user has not supplied a companion .sbt.
+ if (include.Kind == 2)
+ {
+ if (!HasCompanionTimetable)
+ sb.Append("include ").Append(include.FilePath).Append(" end\r\n");
+ continue;
+ }
+ if (include.Selected)
+ sb.Append("include ").Append(include.FilePath).Append(" end\r\n");
+ }
+
+ if (sb.Length == 0)
+ return text;
+
+ return InsertBeforeFirstInit(text, sb.ToString(), fallbackAtEnd: false);
+ }
+
+ ///
+ /// Places generated content ahead of FirstInit, which the simulator
+ /// treats as the end of the definition section.
+ ///
+ private static string InsertBeforeFirstInit(string text, string addition, bool fallbackAtEnd)
+ {
+ var firstInit = Regex.Match(text, @"\bFirstInit\b", RegexOptions.IgnoreCase);
+ if (firstInit.Success)
+ return text[..firstInit.Index] + addition + text[firstInit.Index..];
+
+ return fallbackAtEnd ? text + addition : addition + text;
+ }
+}
diff --git a/StarterNG/Domain/Sceneries/SceneryWeather.cs b/StarterNG/Domain/Sceneries/SceneryWeather.cs
new file mode 100644
index 0000000..36f7054
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/SceneryWeather.cs
@@ -0,0 +1,105 @@
+using System;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Domain.Sceneries;
+
+///
+/// The weather and time-of-day a scenery starts with: what the .scn file
+/// declared, what the user has changed it to, and whether that differs.
+///
+///
+/// Split out of Scenery so the "is this still the authored weather?"
+/// rule lives next to the values it compares, instead of being spread over the
+/// aggregate and the weather tab. The aggregate owns one of these; the parser
+/// fills it and calls once.
+///
+public sealed class SceneryWeather
+{
+ public const int FogMin = 10;
+ public const int FogMax = 10000;
+
+ /// Overcast values the "random" option picks between.
+ private static readonly double[] OvercastPresets = { -1.5, 0, 0.3, 0.7, 1, 1.1, 1.5 };
+
+ private string _authoredTime = "10:30";
+ private string _authoredTimeOverride = "10:30";
+ private int _authoredDay;
+ private double _authoredTemperature = 15;
+ private int _authoredFogEnd = 2000;
+ private double _authoredOvercast;
+ private bool _authoredOvercastRandom;
+
+ public string Time { get; set; } = "10:30";
+
+ public string ScenarioTimeOverride { get; set; } = "10:30";
+
+ /// Day of the year driving the sun position (movelight).
+ public int Day { get; set; }
+
+ public double Temperature { get; set; } = 15;
+
+ public int FogStart { get; set; } = 10;
+
+ public int FogEnd { get; set; } = 2000;
+
+ public double Overcast { get; set; }
+
+ /// When set, each export draws a fresh overcast from the presets.
+ public bool OvercastRandom { get; set; }
+
+ /// True when the .scn actually declared weather, rather than us defaulting it.
+ public bool IsAuthored { get; set; }
+
+ /// Set once the user has touched the weather, so export rewrites the config block.
+ public bool Dirty { get; set; }
+
+ /// Takes the current values as the baseline "what the file said".
+ public void AcceptAsAuthored()
+ {
+ _authoredTime = Time;
+ _authoredTimeOverride = ScenarioTimeOverride;
+ _authoredDay = Day;
+ _authoredTemperature = Temperature;
+ _authoredFogEnd = FogEnd;
+ _authoredOvercast = Overcast;
+ _authoredOvercastRandom = OvercastRandom;
+ }
+
+ /// True when the user's weather differs from what the .scn declared.
+ public bool Changed =>
+ Day != _authoredDay ||
+ ScenarioTimeOverride != _authoredTimeOverride ||
+ Math.Abs(Temperature - _authoredTemperature) > 0.001 ||
+ FogEnd != _authoredFogEnd ||
+ OvercastRandom != _authoredOvercastRandom ||
+ (!OvercastRandom && Math.Abs(Overcast - _authoredOvercast) > 0.001);
+
+ ///
+ /// Puts back the authored weather. Stays : the export must
+ /// still rewrite the config block, because the file on disk may already carry
+ /// an edit from an earlier run.
+ ///
+ public void Restore()
+ {
+ Time = _authoredTime;
+ ScenarioTimeOverride = _authoredTimeOverride;
+ Day = _authoredDay;
+ Temperature = _authoredTemperature;
+ FogEnd = _authoredFogEnd;
+ Overcast = _authoredOvercast;
+ OvercastRandom = _authoredOvercastRandom;
+ Dirty = true;
+ }
+
+ /// Clamps a parsed fog range and picks the distance to run with.
+ public void SetFogRange(int start, int end, IRandomSource random)
+ {
+ FogStart = Math.Clamp(start, FogMin, FogMax);
+ end = Math.Clamp(end, FogStart, FogMax);
+ FogEnd = end > FogStart ? random.Next(FogStart, end + 1) : end;
+ }
+
+ /// The overcast to export, drawing a preset when randomisation is on.
+ public double EffectiveOvercast(IRandomSource random) =>
+ OvercastRandom ? OvercastPresets[random.Next(0, OvercastPresets.Length)] : Overcast;
+}
diff --git a/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
new file mode 100644
index 0000000..2b9d069
--- /dev/null
+++ b/StarterNG/Domain/Sceneries/SceneryWeatherWriter.cs
@@ -0,0 +1,93 @@
+using System;
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Domain.Sceneries;
+
+///
+/// Renders a back into the .scn dialect: strips the
+/// blocks the starter owns and prepends freshly written ones.
+///
+///
+/// A pure text transformation, kept out of the aggregate because it is entirely
+/// about the file format rather than about the scenario.
+///
+public static class SceneryWeatherWriter
+{
+ /// Config lines the starter writes itself and must not carry over.
+ private static readonly Regex ManagedConfigLine = new(
+ @"^\s*(movelight|scenario\.weather\.temperature|scenario\.time\.override)\b",
+ RegexOptions.IgnoreCase | RegexOptions.Compiled);
+
+ public static string Rewrite(string text, SceneryWeather weather, IRandomSource random)
+ {
+ try
+ {
+ 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+", " ");
+
+ var inv = CultureInfo.InvariantCulture;
+ string config =
+ "config\r\n" +
+ $"movelight {weather.Day}\r\n" +
+ $"scenario.weather.temperature {weather.Temperature.ToString(inv)}\r\n" +
+ $"scenario.time.override {DelphiHourMinute(weather.ScenarioTimeOverride)}\r\n" +
+ carried +
+ "endconfig\r\n" +
+ $"atmo 0 0 0 {weather.FogEnd} {weather.FogEnd} 0 0 0 " +
+ $"{weather.EffectiveOvercast(random).ToString(inv)} endatmo\r\n" +
+ $"time {DelphiHourMinute(weather.Time)} 0 0 endtime\r\n";
+
+ return config + stripped;
+ }
+ catch
+ {
+ // A scenery we cannot rewrite is still worth starting unmodified.
+ return text;
+ }
+ }
+
+ ///
+ /// The simulator's parser expects an unpadded hour ("7:30", not "07:30").
+ ///
+ private static string DelphiHourMinute(string hhmm)
+ {
+ int colon = hhmm.IndexOf(':');
+ if (colon <= 0)
+ return hhmm;
+
+ return hhmm[..colon].TrimStart('0') is { Length: > 0 } hour
+ ? hour + hhmm[colon..]
+ : "0" + hhmm[colon..];
+ }
+
+ ///
+ /// Config entries the scenery author wrote that are none of the starter's
+ /// business, preserved verbatim across a rewrite.
+ ///
+ 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");
+ }
+ return sb.ToString();
+ }
+}
diff --git a/StarterNG/Domain/SceneryI18n.cs b/StarterNG/Domain/SceneryI18n.cs
index dd6e001..2b570fd 100644
--- a/StarterNG/Domain/SceneryI18n.cs
+++ b/StarterNG/Domain/SceneryI18n.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using StarterNG.Classes;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Domain;
diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs
index dfdd33f..e4e0aa4 100644
--- a/StarterNG/Domain/TrainsetDisplay.cs
+++ b/StarterNG/Domain/TrainsetDisplay.cs
@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Text;
using StarterNG.Classes;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Domain;
diff --git a/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs b/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs
new file mode 100644
index 0000000..debbfb8
--- /dev/null
+++ b/StarterNG/Infrastructure/Adapters/SystemRandomSource.cs
@@ -0,0 +1,12 @@
+using System;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Infrastructure.Adapters;
+
+/// backed by .
+public sealed class SystemRandomSource : IRandomSource
+{
+ public int Next(int minInclusive, int maxExclusive) => Random.Shared.Next(minInclusive, maxExclusive);
+
+ public double NextDouble() => Random.Shared.NextDouble();
+}
diff --git a/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs b/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs
new file mode 100644
index 0000000..42b95a5
--- /dev/null
+++ b/StarterNG/Infrastructure/Sceneries/SceneryImageLocator.cs
@@ -0,0 +1,65 @@
+using System.Collections.Generic;
+using System.IO;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Sceneries;
+
+namespace StarterNG.Infrastructure.Sceneries;
+
+///
+/// Finds the preview image a scenery names, which authors place in any of
+/// several conventional spots relative to the .scn.
+///
+///
+/// Was a lazy property on the aggregate; moved out because it is a lookup on
+/// disk. Results are cached per scenery path, which is what the lazy field
+/// bought us before.
+///
+public sealed class SceneryImageLocator
+{
+ private readonly IFileSystem _files;
+ private readonly Dictionary _cache = new();
+
+ public SceneryImageLocator(IFileSystem files)
+ {
+ _files = files;
+ }
+
+ /// The image file for this scenery, or null when there is none.
+ public string? Resolve(Scenery scenery)
+ {
+ lock (_cache)
+ {
+ if (_cache.TryGetValue(scenery.Path, out string? cached))
+ return cached;
+
+ string? resolved = Search(scenery);
+ _cache[scenery.Path] = resolved;
+ return resolved;
+ }
+ }
+
+ private string? Search(Scenery scenery)
+ {
+ if (string.IsNullOrWhiteSpace(scenery.ImageName))
+ return null;
+
+ string name = scenery.ImageName.Replace('\\', '/').Trim();
+ string sceneryDir = Path.GetDirectoryName(scenery.Path) ?? ".";
+ string root = Path.GetDirectoryName(sceneryDir) ?? ".";
+
+ string[] candidates =
+ {
+ name,
+ Path.Combine(root, name),
+ Path.Combine(sceneryDir, name),
+ Path.Combine(sceneryDir, "images", name),
+ Path.Combine(root, "scenery", "images", name)
+ };
+
+ foreach (string candidate in candidates)
+ if (_files.FileExists(candidate))
+ return candidate;
+
+ return null;
+ }
+}
diff --git a/StarterNG/Infrastructure/Sceneries/SceneryParser.cs b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs
new file mode 100644
index 0000000..7897255
--- /dev/null
+++ b/StarterNG/Infrastructure/Sceneries/SceneryParser.cs
@@ -0,0 +1,297 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+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;
+
+///
+/// Turns the text of a .scn file into a .
+///
+///
+/// All knowledge of the .scn dialect lives here, so the aggregate stays free of
+/// regular expressions and the parser can be exercised on a string. Anything
+/// malformed is recorded as a fault rather than thrown: a scenery with one bad
+/// consist is still worth listing.
+///
+public sealed class SceneryParser
+{
+ private readonly IClock _clock;
+ private readonly IRandomSource _random;
+
+ public SceneryParser(IClock clock, IRandomSource random)
+ {
+ _clock = clock;
+ _random = random;
+ }
+
+ public Scenery Parse(string path, string content)
+ {
+ // Directives, attachments and weather are read from the file as written;
+ // the placeholder rewriting below only shapes the export template.
+ string original = content;
+
+ var faults = new List();
+ var attachments = ParseAttachments(original);
+
+ var includes = new List();
+ content = StripOptionalIncludes(content, includes);
+
+ // Trainsets are lifted out first so the placeholders survive the loose
+ // vehicle sweep below, which would otherwise match nodes inside them.
+ 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);
+
+ var looseVehicles = new List();
+ var looseNames = new List();
+ content = ExtractLooseVehicles(content, looseVehicles, looseNames, faults);
+
+ var scenery = new Scenery(path, content)
+ {
+ Group = MatchDirective(original, "l"),
+ Name = MatchDirective(original, "n"),
+ Description = MatchAllDirectives(original, "d"),
+ ImageName = MatchDirective(original, "i"),
+ Archival = MatchDirective(original, "a") != null
+ };
+
+ scenery.Attachments.AddRange(attachments);
+ scenery.Includes.AddRange(includes);
+ scenery.LooseVehicles.AddRange(looseVehicles);
+ scenery.LooseVehicleNames.AddRange(looseNames);
+ scenery.Faults.AddRange(faults);
+
+ ParseWeather(original, 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 List ParseAttachments(string content)
+ {
+ var attachments = new List();
+
+ foreach (Match m in Regex.Matches(content, @"^//\$f\b[ \t]*([^\r\n]*)", RegexOptions.Multiline))
+ {
+ string rest = m.Groups[1].Value.Trim();
+ if (rest.Length == 0)
+ continue;
+
+ string[] parts = rest.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
+
+ // A leading one- or two-character token is a legacy ordering hint.
+ if (parts.Length >= 3 && parts[0].Length <= 2)
+ attachments.Add(new SceneryAttachment
+ {
+ FilePath = parts[1],
+ Label = string.Join(" ", parts.Skip(2))
+ });
+ else if (parts.Length >= 2)
+ attachments.Add(new SceneryAttachment
+ {
+ FilePath = parts[0],
+ Label = string.Join(" ", parts.Skip(1))
+ });
+ else
+ attachments.Add(new SceneryAttachment
+ {
+ FilePath = parts[0],
+ Label = System.IO.Path.GetFileName(parts[0])
+ });
+ }
+
+ return attachments;
+ }
+
+ ///
+ /// Removes the includes the scenery marked $optional from the text and
+ /// records them, so the user can pick which ones the export puts back.
+ ///
+ private static string StripOptionalIncludes(string content, List includes)
+ {
+ if (content.IndexOf("$optional", StringComparison.OrdinalIgnoreCase) < 0)
+ return content;
+
+ return Regex.Replace(
+ content,
+ @"include\s+(\S+(?:\s+\S+)*?)\s+end\s*//[^\r\n]*\$optional([^\r\n]*)\r?\n([\s\S]*?)endoptional\b",
+ m =>
+ {
+ string includePath = Regex.Replace(m.Groups[1].Value.Trim(), @"\s+", " ");
+ string raw = m.Groups[2].Value.Trim().TrimStart(',', ' ');
+ string[] parameters = raw.Split(new[] { ',', '|', ';' },
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+
+ int kind = 1;
+ string description = includePath;
+ if (parameters.Length >= 1 && int.TryParse(parameters[0], out int parsedKind))
+ kind = parsedKind;
+ if (parameters.Length >= 2)
+ description = parameters[1];
+
+ includes.Add(new SceneryInclude
+ {
+ FilePath = includePath,
+ Desc = description,
+ Kind = kind,
+ Selected = kind == 1
+ });
+ return " ";
+ },
+ RegexOptions.IgnoreCase);
+ }
+
+ ///
+ /// Lifts node ... dynamic ... enddynamic entries that sit outside any
+ /// trainset, so the depot can show and reorder them like a consist.
+ ///
+ private static string ExtractLooseVehicles(string content, List vehicles, List names,
+ List faults)
+ {
+ var inv = CultureInfo.InvariantCulture;
+
+ 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 =>
+ {
+ string name = m.Groups[3].Value;
+
+ // A placeholder means the node belongs to a trainset already lifted out.
+ 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
+ {
+ // Leave the node in the template so the export stays faithful.
+ faults.Add($"# loose dynamic parse fault: {name}");
+ names.Add(name);
+ return m.Value;
+ }
+ });
+ }
+
+ private void ParseWeather(string content, SceneryWeather weather)
+ {
+ var time = Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
+ if (time.Success)
+ {
+ weather.Time = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}";
+ weather.ScenarioTimeOverride = weather.Time;
+ }
+
+ var over = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})");
+ if (over.Success)
+ {
+ weather.ScenarioTimeOverride = $"{over.Groups[1].Value.PadLeft(2, '0')}:{over.Groups[2].Value}";
+ weather.IsAuthored = true;
+ }
+ else if (!time.Success)
+ {
+ // Nothing authored: start the scenario at the player's wall clock.
+ var now = _clock.Now;
+ weather.ScenarioTimeOverride = $"{now.Hour:D2}:{now.Minute:D2}";
+ }
+
+ var move = Regex.Match(content, @"(?im)\bmovelight\s+(-?\d+)");
+ if (move.Success && int.TryParse(move.Groups[1].Value, out int day))
+ {
+ weather.Day = day;
+ weather.IsAuthored = true;
+ }
+
+ var temperature = Regex.Match(content, @"(?i)scenario\.weather\.temperature\s+(-?\d+(?:\.\d+)?)");
+ if (temperature.Success &&
+ double.TryParse(temperature.Groups[1].Value, NumberStyles.Float, CultureInfo.InvariantCulture,
+ out double celsius))
+ {
+ weather.Temperature = celsius;
+ weather.IsAuthored = true;
+ }
+
+ var atmo = Regex.Match(content, @"(?is)\batmo\b(.*?)\bendatmo\b");
+ if (!atmo.Success)
+ return;
+
+ var numbers = Regex.Matches(atmo.Groups[1].Value, @"-?\d+(?:\.\d+)?")
+ .Select(m => m.Value)
+ .ToList();
+
+ if (numbers.Count >= 5 &&
+ int.TryParse(numbers[3], out int fogStart) &&
+ int.TryParse(numbers[4], out int fogEnd))
+ {
+ weather.SetFogRange(fogStart, fogEnd, _random);
+ weather.IsAuthored = true;
+ }
+
+ if (numbers.Count >= 6 &&
+ double.TryParse(numbers[^1], NumberStyles.Float, CultureInfo.InvariantCulture, out double overcast))
+ weather.Overcast = overcast;
+ }
+
+ private static string? MatchAllDirectives(string content, string letter)
+ {
+ var matches = Regex.Matches(content, @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
+ return matches.Count == 0
+ ? null
+ : string.Join("\n", matches.Select(m => m.Groups[1].Value.TrimEnd()));
+ }
+
+ private static string? MatchDirective(string content, string letter)
+ {
+ var match = Regex.Match(content, @"^//\$" + letter + @"\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
+ return match.Success ? match.Groups[1].Value.Trim() : null;
+ }
+}
diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
new file mode 100644
index 0000000..afcb536
--- /dev/null
+++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs
@@ -0,0 +1,101 @@
+using System;
+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.Sceneries;
+
+namespace StarterNG.Infrastructure.Sceneries;
+
+///
+/// Loads scenarios from the installation's scenery folder.
+///
+///
+/// Sceneries are code page 1250 text, and a large installation holds hundreds of
+/// them, so the sweep is parallel; each file that throws is logged and dropped
+/// rather than taking the whole load down.
+///
+public sealed class SceneryRepository : ISceneryRepository
+{
+ /// Files starting with '$' are authoring scratch, not scenarios.
+ private const char ExcludedPrefix = '$';
+
+ private readonly IFileSystem _files;
+ private readonly IGamePaths _paths;
+ private readonly IDiagnosticsLog _log;
+ private readonly SceneryParser _parser;
+ private readonly Encoding _encoding;
+
+ public SceneryRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log, SceneryParser parser)
+ {
+ _files = files;
+ _paths = paths;
+ _log = log;
+ _parser = parser;
+ _encoding = CodePage1250();
+ }
+
+ public IReadOnlyList LoadAll(IProgress? progress = null)
+ {
+ var files = SceneryFiles().ToList();
+ var loaded = new ConcurrentBag();
+ int total = Math.Max(1, files.Count);
+ int done = 0;
+
+ Parallel.ForEach(files,
+ new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount },
+ file =>
+ {
+ var scenery = Load(file);
+ if (scenery is not null)
+ loaded.Add(scenery);
+
+ progress?.Report(new SceneryLoadProgress(Interlocked.Increment(ref done), total,
+ Path.GetFileName(file)));
+ });
+
+ return loaded.ToList();
+ }
+
+ public Scenery? Load(string path)
+ {
+ try
+ {
+ if (!_files.FileExists(path))
+ return null;
+
+ var scenery = _parser.Parse(path, _files.ReadAllText(path, _encoding));
+ scenery.HasCompanionTimetable = _files.FileExists(Path.ChangeExtension(path, ".sbt"));
+ return scenery;
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"scenery/{Path.GetFileName(path)}", ex);
+ return null;
+ }
+ }
+
+ private IEnumerable SceneryFiles() =>
+ _files.GetFiles(_paths.Scenery, "*.scn")
+ .Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix);
+
+ ///
+ /// The .scn dialect predates Unicode. The provider is registered by the entry
+ /// point; fall back to Latin-1 if that ever fails so loading still works.
+ ///
+ private static Encoding CodePage1250()
+ {
+ try
+ {
+ return Encoding.GetEncoding(1250);
+ }
+ catch (Exception)
+ {
+ return Encoding.Latin1;
+ }
+ }
+}
diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs
index 7dbe6d8..6e80542 100644
--- a/StarterNG/MainWindow.axaml.cs
+++ b/StarterNG/MainWindow.axaml.cs
@@ -17,6 +17,8 @@ using StarterNG.Infrastructure;
using StarterNG.Services;
using StarterNG.Views;
using SettingsModel = StarterNG.Classes.Settings;
+using StarterNG.Application;
+using StarterNG.Domain.Sceneries;
namespace StarterNG;
@@ -443,14 +445,14 @@ public partial class MainWindow : Window
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
- scenery.WeatherDirty = true;
+ scenery.Weather.Dirty = true;
string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery";
string exportName = "$" + Path.GetFileName(scenery.Path);
string exportPath = Path.Combine(dir, exportName);
try
{
- File.WriteAllText(exportPath, scenery.BuildExportContent(SettingsModel.Instance.IgnoreIrrelevantTrains), Encoding.GetEncoding(1250));
+ File.WriteAllText(exportPath, scenery.BuildExportContent(SettingsModel.Instance.IgnoreIrrelevantTrains, AppServices.Current.Random), Encoding.GetEncoding(1250));
}
catch (Exception ex)
{
diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs
index d527ec0..0146036 100644
--- a/StarterNG/Views/Depot.axaml.cs
+++ b/StarterNG/Views/Depot.axaml.cs
@@ -18,6 +18,7 @@ using Avalonia.Threading;
using Avalonia.VisualTree;
using StarterNG.Classes;
using StarterNG.Domain;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Views;
diff --git a/StarterNG/Views/Depot/VehicleBrowser.cs b/StarterNG/Views/Depot/VehicleBrowser.cs
index 08ad464..9c4d123 100644
--- a/StarterNG/Views/Depot/VehicleBrowser.cs
+++ b/StarterNG/Views/Depot/VehicleBrowser.cs
@@ -14,6 +14,7 @@ using Avalonia.Threading;
using StarterNG.Classes;
using StarterNG.Domain;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Views;
diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs
index 46d8299..2a7536c 100644
--- a/StarterNG/Views/Scenarios.axaml.cs
+++ b/StarterNG/Views/Scenarios.axaml.cs
@@ -17,6 +17,8 @@ using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using StarterNG.Classes;
using StarterNG.Domain;
+using StarterNG.Application;
+using StarterNG.Domain.Sceneries;
namespace StarterNG.Views;
@@ -310,7 +312,7 @@ public partial class Scenarios : UserControl
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
sceneryDescription.Text = scenery.LocalizedDescription;
- string? imagePath = scenery.ImagePath;
+ string? imagePath = AppServices.Current.SceneryImages.Resolve(scenery);
if (imagePath is not null)
{
try
@@ -551,16 +553,17 @@ public partial class Scenarios : UserControl
try
{
weatherForm.IsEnabled = true;
- weatherTime.SelectedTime = ParseTime(scenery.ScenarioTimeOverride);
- weatherDate.SelectedDate = DateOfDayOfYear(scenery.Day);
- weatherTemp.Value = scenery.Temperature;
- weatherFog.Value = SliderFromFog(scenery.FogEnd);
- SelectByTag(weatherSeason, scenery.Day.ToString(), excludeFromNearest: "0");
- SelectByTag(weatherOvercast, scenery.OvercastRandom
+ var weather = scenery.Weather;
+ weatherTime.SelectedTime = ParseTime(weather.ScenarioTimeOverride);
+ weatherDate.SelectedDate = DateOfDayOfYear(weather.Day);
+ weatherTemp.Value = weather.Temperature;
+ weatherFog.Value = SliderFromFog(weather.FogEnd);
+ SelectByTag(weatherSeason, weather.Day.ToString(), excludeFromNearest: "0");
+ SelectByTag(weatherOvercast, weather.OvercastRandom
? RandomTag
- : scenery.Overcast.ToString(CultureInfo.InvariantCulture));
+ : weather.Overcast.ToString(CultureInfo.InvariantCulture));
- ApplyTodayPreview(scenery.Day == 0);
+ ApplyTodayPreview(weather.Day == 0);
UpdateWeatherLabels();
}
finally
@@ -574,22 +577,22 @@ public partial class Scenarios : UserControl
if (_loadingWeather || _currentScenery is null)
return;
- var scn = _currentScenery;
+ var weather = _currentScenery.Weather;
if (weatherTime.SelectedTime is { } t)
- scn.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
+ weather.ScenarioTimeOverride = $"{t.Hours:D2}:{t.Minutes:D2}";
- scn.Day = _todaySeason ? 0 : SelectedDayOfYear();
- scn.Temperature = weatherTemp.Value;
- scn.FogEnd = FogFromSlider(weatherFog.Value);
+ weather.Day = _todaySeason ? 0 : SelectedDayOfYear();
+ weather.Temperature = weatherTemp.Value;
+ weather.FogEnd = FogFromSlider(weatherFog.Value);
if ((weatherOvercast.SelectedItem as ComboBoxItem)?.Tag is string ov)
{
- scn.OvercastRandom = ov == RandomTag;
- if (!scn.OvercastRandom &&
+ weather.OvercastRandom = ov == RandomTag;
+ if (!weather.OvercastRandom &&
double.TryParse(ov, NumberStyles.Float, CultureInfo.InvariantCulture, out double oc))
- scn.Overcast = oc;
+ weather.Overcast = oc;
}
- scn.WeatherDirty = true;
+ weather.Dirty = true;
UpdateWeatherLabels();
}
@@ -599,23 +602,23 @@ public partial class Scenarios : UserControl
weatherFogLabel.Text = FormatFog(FogFromSlider(weatherFog.Value));
weatherDayDate.Text = string.Format(LanguageCulture, App.Loc["DayOfYear"], SelectedDayOfYear());
- weatherRestore.IsEnabled = _currentScenery?.WeatherChanged ?? false;
+ weatherRestore.IsEnabled = _currentScenery?.Weather.Changed ?? false;
}
private static int FogFromSlider(double position)
{
- double metres = Scenery.FogMin *
- System.Math.Pow((double)Scenery.FogMax / Scenery.FogMin, position / 1000.0);
+ double metres = SceneryWeather.FogMin *
+ System.Math.Pow((double)SceneryWeather.FogMax / SceneryWeather.FogMin, position / 1000.0);
int step = metres < 100 ? 5 : metres < 1000 ? 10 : 100;
return System.Math.Clamp((int)(System.Math.Round(metres / step) * step),
- Scenery.FogMin, Scenery.FogMax);
+ SceneryWeather.FogMin, SceneryWeather.FogMax);
}
private static double SliderFromFog(int metres)
{
- double clamped = System.Math.Clamp(metres, Scenery.FogMin, Scenery.FogMax);
- return 1000.0 * System.Math.Log(clamped / Scenery.FogMin) /
- System.Math.Log((double)Scenery.FogMax / Scenery.FogMin);
+ double clamped = System.Math.Clamp(metres, SceneryWeather.FogMin, SceneryWeather.FogMax);
+ return 1000.0 * System.Math.Log(clamped / SceneryWeather.FogMin) /
+ System.Math.Log((double)SceneryWeather.FogMax / SceneryWeather.FogMin);
}
private static string FormatFog(int metres) =>
@@ -688,7 +691,7 @@ public partial class Scenarios : UserControl
private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e)
{
if (_currentScenery is null) return;
- _currentScenery.RestoreWeather();
+ _currentScenery.Weather.Restore();
ShowWeather(_currentScenery);
}