Make Scenery a domain aggregate with no I/O

Parsing and loading move to Infrastructure/Sceneries. Weather becomes a value
object owning the "does this differ from the file" rule.
This commit is contained in:
maj00r
2026-09-04 20:53:50 +02:00
parent a3137a27c4
commit 789f0dd1c9
25 changed files with 1465 additions and 624 deletions

View File

@@ -0,0 +1,22 @@
using StarterNG.Application.Abstractions;
namespace StarterNG.Tests.Fakes;
/// <summary>
/// Randomness pinned to the low end of every range, so weather that varies in
/// production is deterministic in tests.
/// </summary>
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;
}

View File

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

View File

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

View File

@@ -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<int>();
Build(files, out _).LoadAll(new Progress<StarterNG.Application.Abstractions.SceneryLoadProgress>(
step => { lock (steps) steps.Add(step.Loaded); }));
// Progress<T> 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));
}
}

View File

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

View File

@@ -0,0 +1,15 @@
namespace StarterNG.Application.Abstractions;
/// <summary>
/// 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.
/// </summary>
public interface IRandomSource
{
/// <summary>Inclusive lower bound, exclusive upper bound.</summary>
int Next(int minInclusive, int maxExclusive);
/// <summary>Uniform value in [0, 1).</summary>
double NextDouble();
}

View File

@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Application.Abstractions;
/// <summary>How far a scenery sweep has got, for the splash screen.</summary>
public readonly record struct SceneryLoadProgress(int Loaded, int Total, string FileName);
/// <summary>
/// Reads the scenarios of an installation. The only route from a .scn on disk to
/// a <see cref="Scenery"/>; nothing else constructs one from a path.
/// </summary>
public interface ISceneryRepository
{
/// <summary>
/// Every scenario in the installation, in no particular order. Files that
/// fail to parse are logged and skipped rather than failing the load.
/// </summary>
IReadOnlyList<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null);
/// <summary>Reloads a single scenario; null when it is gone or unreadable.</summary>
Scenery? Load(string path);
}

View File

@@ -1,6 +1,7 @@
using System; using System;
using StarterNG.Application.Abstractions; using StarterNG.Application.Abstractions;
using StarterNG.Infrastructure.Adapters; using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries;
using StarterNG.Services; using StarterNG.Services;
namespace StarterNG.Application; namespace StarterNG.Application;
@@ -33,8 +34,11 @@ public sealed class AppServices
var log = new FileDiagnosticsLog(files, clock, paths); var log = new FileDiagnosticsLog(files, clock, paths);
Log = log; Log = log;
Random = new SystemRandomSource();
Processes = new SystemProcessLauncher(log); Processes = new SystemProcessLauncher(log);
Localization = new LocalizationService(log); Localization = new LocalizationService(log);
Sceneries = new SceneryRepository(files, paths, log, new SceneryParser(clock, Random));
SceneryImages = new SceneryImageLocator(files);
} }
/// <summary> /// <summary>
@@ -66,6 +70,12 @@ public sealed class AppServices
public IProcessLauncher Processes { get; } public IProcessLauncher Processes { get; }
public IRandomSource Random { get; }
public ISceneryRepository Sceneries { get; }
public SceneryImageLocator SceneryImages { get; }
/// <summary> /// <summary>
/// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/> /// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/>
/// because the XAML binds to it as a change-notifying source; code should take /// because the XAML binds to it as a change-notifying source; code should take

View File

@@ -1,4 +1,5 @@
using System; using System;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes; namespace StarterNG.Classes;

View File

@@ -4,6 +4,9 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using StarterNG.Application;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Classes; namespace StarterNG.Classes;
@@ -26,17 +29,12 @@ public sealed class GameData
public bool Loaded { get; private set; } public bool Loaded { get; private set; }
public void Load(IProgress<LoadStatus>? progress = null, public void Load(IProgress<LoadStatus>? progress = null,
string sceneryDir = "scenery/",
string miniDir = "textures/mini/", string miniDir = "textures/mini/",
string dynamicRoot = "dynamic") string dynamicRoot = "dynamic")
{ {
if (Loaded) if (Loaded)
return; return;
var scnFiles = EnumerateScenery(sceneryDir);
int total = Math.Max(1, scnFiles.Count);
int done = 0;
progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt")); progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
int vehicles = Vehicles.LoadFromTexturesTxt(dynamicRoot); int vehicles = Vehicles.LoadFromTexturesTxt(dynamicRoot);
if (vehicles == 0) if (vehicles == 0)
@@ -46,61 +44,19 @@ public sealed class GameData
() => VehicleDatabase.PreloadMiniIndex(miniDir), () => VehicleDatabase.PreloadMiniIndex(miniDir),
() => Physics.PreloadIndex()); () => Physics.PreloadIndex());
var sceneryLock = new object(); var sceneryProgress = progress is null
var doneCount = done; ? null
Parallel.ForEach(scnFiles, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount }, : new Progress<SceneryLoadProgress>(step => progress.Report(
file => new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName)));
{ ReloadSceneries(sceneryProgress);
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;
progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null)); progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
Loaded = true; Loaded = true;
} }
public void ReloadSceneries(string sceneryDir = "scenery/") public void ReloadSceneries(IProgress<SceneryLoadProgress>? progress = null)
{ {
Sceneries.Clear(); Sceneries.Clear();
var scnFiles = EnumerateScenery(sceneryDir); Sceneries.AddRange(AppServices.Current.Sceneries.LoadAll(progress));
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<string> EnumerateScenery(string sceneryDir)
{
if (!Directory.Exists(sceneryDir))
return new List<string>();
return Directory.GetFiles(sceneryDir, "*.scn")
.Where(p => !Path.GetFileName(p).StartsWith("$"))
.ToList();
} }
} }

View File

@@ -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<string> Lines;
public List<Trainset> Trainsets;
public List<SceneryAttachment> Attachments = new();
public List<SceneryInclude> Includes = new();
public List<string> LooseVehicleNames = new();
public List<Dynamic> LooseVehicles = new();
public List<string> 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<Trainset>();
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<string> trainsetEntries = new List<string>();
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<string>
{
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;
}
}

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Domain; namespace StarterNG.Domain;

View File

@@ -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;
/// <summary>A file the scenery offers to open alongside it (map, manual, ...).</summary>
public sealed class SceneryAttachment
{
public string FilePath = "";
public string Label = "";
}
/// <summary>
/// An <c>include</c> the scenery marks as optional, so the user can decide
/// whether it ends up in the exported scenario.
/// </summary>
public sealed class SceneryInclude
{
public string FilePath = "";
public string Desc = "";
/// <summary>1 = on by default, 2 = only when no companion timetable exists.</summary>
public int Kind;
public bool Selected;
}
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The aggregate is pure: it never reads or writes files. Parsing lives in
/// <c>Infrastructure.Sceneries.SceneryParser</c> and loading in
/// <c>SceneryRepository</c>, which is what lets the export rules be tested from
/// a string rather than an installation.
///
/// <see cref="Template"/> is the .scn text with every trainset replaced by a
/// <c>{{index}}</c> placeholder and the optional includes stripped, so
/// <see cref="BuildExportContent"/> only has to substitute rather than re-parse.
/// </remarks>
public sealed class Scenery
{
public Scenery(string path, string template)
{
Path = path;
Template = template;
}
/// <summary>Path of the .scn this was parsed from; the aggregate's identity.</summary>
public string Path { get; }
/// <summary>The .scn body with trainset placeholders, used for export.</summary>
public string Template { get; }
/// <summary>True when a companion .sbt timetable sits next to the .scn.</summary>
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; }
/// <summary>Scenery kept for historical interest; hidden unless asked for.</summary>
public bool Archival { get; set; }
public List<Trainset> Trainsets { get; } = new();
public List<SceneryAttachment> Attachments { get; } = new();
public List<SceneryInclude> Includes { get; } = new();
/// <summary>Vehicles placed directly as nodes rather than inside a trainset.</summary>
public List<Dynamic> LooseVehicles { get; } = new();
/// <summary>
/// Names of every loose vehicle, including those whose node failed to parse
/// and therefore has no <see cref="Dynamic"/>.
/// </summary>
public List<string> LooseVehicleNames { get; } = new();
/// <summary>Parse problems worth showing on the scenario's fault list.</summary>
public List<string> 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));
/// <summary>
/// Renders the scenario the simulator should load: the original file with the
/// user's consists, weather and optional includes applied.
/// </summary>
/// <param name="skipDecorTrainsets">
/// Drops AI trainsets the user marked irrelevant, which shortens loading.
/// </param>
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);
}
/// <summary>
/// Places generated content ahead of <c>FirstInit</c>, which the simulator
/// treats as the end of the definition section.
/// </summary>
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;
}
}

View File

@@ -0,0 +1,105 @@
using System;
using StarterNG.Application.Abstractions;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// Split out of <c>Scenery</c> 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 <see cref="AcceptAsAuthored"/> once.
/// </remarks>
public sealed class SceneryWeather
{
public const int FogMin = 10;
public const int FogMax = 10000;
/// <summary>Overcast values the "random" option picks between.</summary>
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";
/// <summary>Day of the year driving the sun position (movelight).</summary>
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; }
/// <summary>When set, each export draws a fresh overcast from the presets.</summary>
public bool OvercastRandom { get; set; }
/// <summary>True when the .scn actually declared weather, rather than us defaulting it.</summary>
public bool IsAuthored { get; set; }
/// <summary>Set once the user has touched the weather, so export rewrites the config block.</summary>
public bool Dirty { get; set; }
/// <summary>Takes the current values as the baseline "what the file said".</summary>
public void AcceptAsAuthored()
{
_authoredTime = Time;
_authoredTimeOverride = ScenarioTimeOverride;
_authoredDay = Day;
_authoredTemperature = Temperature;
_authoredFogEnd = FogEnd;
_authoredOvercast = Overcast;
_authoredOvercastRandom = OvercastRandom;
}
/// <summary>True when the user's weather differs from what the .scn declared.</summary>
public bool Changed =>
Day != _authoredDay ||
ScenarioTimeOverride != _authoredTimeOverride ||
Math.Abs(Temperature - _authoredTemperature) > 0.001 ||
FogEnd != _authoredFogEnd ||
OvercastRandom != _authoredOvercastRandom ||
(!OvercastRandom && Math.Abs(Overcast - _authoredOvercast) > 0.001);
/// <summary>
/// Puts back the authored weather. Stays <see cref="Dirty"/>: the export must
/// still rewrite the config block, because the file on disk may already carry
/// an edit from an earlier run.
/// </summary>
public void Restore()
{
Time = _authoredTime;
ScenarioTimeOverride = _authoredTimeOverride;
Day = _authoredDay;
Temperature = _authoredTemperature;
FogEnd = _authoredFogEnd;
Overcast = _authoredOvercast;
OvercastRandom = _authoredOvercastRandom;
Dirty = true;
}
/// <summary>Clamps a parsed fog range and picks the distance to run with.</summary>
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;
}
/// <summary>The overcast to export, drawing a preset when randomisation is on.</summary>
public double EffectiveOvercast(IRandomSource random) =>
OvercastRandom ? OvercastPresets[random.Next(0, OvercastPresets.Length)] : Overcast;
}

View File

@@ -0,0 +1,93 @@
using System;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using StarterNG.Application.Abstractions;
namespace StarterNG.Domain.Sceneries;
/// <summary>
/// Renders a <see cref="SceneryWeather"/> back into the .scn dialect: strips the
/// blocks the starter owns and prepends freshly written ones.
/// </summary>
/// <remarks>
/// A pure text transformation, kept out of the aggregate because it is entirely
/// about the file format rather than about the scenario.
/// </remarks>
public static class SceneryWeatherWriter
{
/// <summary>Config lines the starter writes itself and must not carry over.</summary>
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;
}
}
/// <summary>
/// The simulator's parser expects an unpadded hour ("7:30", not "07:30").
/// </summary>
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..];
}
/// <summary>
/// Config entries the scenery author wrote that are none of the starter's
/// business, preserved verbatim across a rewrite.
/// </summary>
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();
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Domain; namespace StarterNG.Domain;

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Domain; namespace StarterNG.Domain;

View File

@@ -0,0 +1,12 @@
using System;
using StarterNG.Application.Abstractions;
namespace StarterNG.Infrastructure.Adapters;
/// <summary><see cref="IRandomSource"/> backed by <see cref="Random.Shared"/>.</summary>
public sealed class SystemRandomSource : IRandomSource
{
public int Next(int minInclusive, int maxExclusive) => Random.Shared.Next(minInclusive, maxExclusive);
public double NextDouble() => Random.Shared.NextDouble();
}

View File

@@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.IO;
using StarterNG.Application.Abstractions;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Infrastructure.Sceneries;
/// <summary>
/// Finds the preview image a scenery names, which authors place in any of
/// several conventional spots relative to the .scn.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class SceneryImageLocator
{
private readonly IFileSystem _files;
private readonly Dictionary<string, string?> _cache = new();
public SceneryImageLocator(IFileSystem files)
{
_files = files;
}
/// <summary>The image file for this scenery, or null when there is none.</summary>
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;
}
}

View File

@@ -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;
/// <summary>
/// Turns the text of a .scn file into a <see cref="Scenery"/>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<string>();
var attachments = ParseAttachments(original);
var includes = new List<SceneryInclude>();
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<string>();
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<Dynamic>();
var looseNames = new List<string>();
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<SceneryAttachment> ParseAttachments(string content)
{
var attachments = new List<SceneryAttachment>();
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;
}
/// <summary>
/// Removes the includes the scenery marked <c>$optional</c> from the text and
/// records them, so the user can pick which ones the export puts back.
/// </summary>
private static string StripOptionalIncludes(string content, List<SceneryInclude> 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);
}
/// <summary>
/// Lifts <c>node ... dynamic ... enddynamic</c> entries that sit outside any
/// trainset, so the depot can show and reorder them like a consist.
/// </summary>
private static string ExtractLooseVehicles(string content, List<Dynamic> vehicles, List<string> names,
List<string> 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;
}
}

View File

@@ -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;
/// <summary>
/// Loads scenarios from the installation's scenery folder.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class SceneryRepository : ISceneryRepository
{
/// <summary>Files starting with '$' are authoring scratch, not scenarios.</summary>
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<Scenery> LoadAll(IProgress<SceneryLoadProgress>? progress = null)
{
var files = SceneryFiles().ToList();
var loaded = new ConcurrentBag<Scenery>();
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<string> SceneryFiles() =>
_files.GetFiles(_paths.Scenery, "*.scn")
.Where(path => Path.GetFileName(path).FirstOrDefault() != ExcludedPrefix);
/// <summary>
/// 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.
/// </summary>
private static Encoding CodePage1250()
{
try
{
return Encoding.GetEncoding(1250);
}
catch (Exception)
{
return Encoding.Latin1;
}
}
}

View File

@@ -17,6 +17,8 @@ using StarterNG.Infrastructure;
using StarterNG.Services; using StarterNG.Services;
using StarterNG.Views; using StarterNG.Views;
using SettingsModel = StarterNG.Classes.Settings; using SettingsModel = StarterNG.Classes.Settings;
using StarterNG.Application;
using StarterNG.Domain.Sceneries;
namespace StarterNG; namespace StarterNG;
@@ -443,14 +445,14 @@ public partial class MainWindow : Window
LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path));
scenery.WeatherDirty = true; scenery.Weather.Dirty = true;
string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery"; string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery";
string exportName = "$" + Path.GetFileName(scenery.Path); string exportName = "$" + Path.GetFileName(scenery.Path);
string exportPath = Path.Combine(dir, exportName); string exportPath = Path.Combine(dir, exportName);
try 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) catch (Exception ex)
{ {

View File

@@ -18,6 +18,7 @@ using Avalonia.Threading;
using Avalonia.VisualTree; using Avalonia.VisualTree;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Domain; using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Views; namespace StarterNG.Views;

View File

@@ -14,6 +14,7 @@ using Avalonia.Threading;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Domain; using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Views; namespace StarterNG.Views;

View File

@@ -17,6 +17,8 @@ using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using StarterNG.Classes; using StarterNG.Classes;
using StarterNG.Domain; using StarterNG.Domain;
using StarterNG.Application;
using StarterNG.Domain.Sceneries;
namespace StarterNG.Views; namespace StarterNG.Views;
@@ -310,7 +312,7 @@ public partial class Scenarios : UserControl
SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode); SceneryI18n.LoadFor(scenery, App.Loc.CurrentLangCode);
sceneryDescription.Text = scenery.LocalizedDescription; sceneryDescription.Text = scenery.LocalizedDescription;
string? imagePath = scenery.ImagePath; string? imagePath = AppServices.Current.SceneryImages.Resolve(scenery);
if (imagePath is not null) if (imagePath is not null)
{ {
try try
@@ -551,16 +553,17 @@ public partial class Scenarios : UserControl
try try
{ {
weatherForm.IsEnabled = true; weatherForm.IsEnabled = true;
weatherTime.SelectedTime = ParseTime(scenery.ScenarioTimeOverride); var weather = scenery.Weather;
weatherDate.SelectedDate = DateOfDayOfYear(scenery.Day); weatherTime.SelectedTime = ParseTime(weather.ScenarioTimeOverride);
weatherTemp.Value = scenery.Temperature; weatherDate.SelectedDate = DateOfDayOfYear(weather.Day);
weatherFog.Value = SliderFromFog(scenery.FogEnd); weatherTemp.Value = weather.Temperature;
SelectByTag(weatherSeason, scenery.Day.ToString(), excludeFromNearest: "0"); weatherFog.Value = SliderFromFog(weather.FogEnd);
SelectByTag(weatherOvercast, scenery.OvercastRandom SelectByTag(weatherSeason, weather.Day.ToString(), excludeFromNearest: "0");
SelectByTag(weatherOvercast, weather.OvercastRandom
? RandomTag ? RandomTag
: scenery.Overcast.ToString(CultureInfo.InvariantCulture)); : weather.Overcast.ToString(CultureInfo.InvariantCulture));
ApplyTodayPreview(scenery.Day == 0); ApplyTodayPreview(weather.Day == 0);
UpdateWeatherLabels(); UpdateWeatherLabels();
} }
finally finally
@@ -574,22 +577,22 @@ public partial class Scenarios : UserControl
if (_loadingWeather || _currentScenery is null) if (_loadingWeather || _currentScenery is null)
return; return;
var scn = _currentScenery; var weather = _currentScenery.Weather;
if (weatherTime.SelectedTime is { } t) 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(); weather.Day = _todaySeason ? 0 : SelectedDayOfYear();
scn.Temperature = weatherTemp.Value; weather.Temperature = weatherTemp.Value;
scn.FogEnd = FogFromSlider(weatherFog.Value); weather.FogEnd = FogFromSlider(weatherFog.Value);
if ((weatherOvercast.SelectedItem as ComboBoxItem)?.Tag is string ov) if ((weatherOvercast.SelectedItem as ComboBoxItem)?.Tag is string ov)
{ {
scn.OvercastRandom = ov == RandomTag; weather.OvercastRandom = ov == RandomTag;
if (!scn.OvercastRandom && if (!weather.OvercastRandom &&
double.TryParse(ov, NumberStyles.Float, CultureInfo.InvariantCulture, out double oc)) double.TryParse(ov, NumberStyles.Float, CultureInfo.InvariantCulture, out double oc))
scn.Overcast = oc; weather.Overcast = oc;
} }
scn.WeatherDirty = true; weather.Dirty = true;
UpdateWeatherLabels(); UpdateWeatherLabels();
} }
@@ -599,23 +602,23 @@ public partial class Scenarios : UserControl
weatherFogLabel.Text = FormatFog(FogFromSlider(weatherFog.Value)); weatherFogLabel.Text = FormatFog(FogFromSlider(weatherFog.Value));
weatherDayDate.Text = string.Format(LanguageCulture, App.Loc["DayOfYear"], SelectedDayOfYear()); 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) private static int FogFromSlider(double position)
{ {
double metres = Scenery.FogMin * double metres = SceneryWeather.FogMin *
System.Math.Pow((double)Scenery.FogMax / Scenery.FogMin, position / 1000.0); System.Math.Pow((double)SceneryWeather.FogMax / SceneryWeather.FogMin, position / 1000.0);
int step = metres < 100 ? 5 : metres < 1000 ? 10 : 100; int step = metres < 100 ? 5 : metres < 1000 ? 10 : 100;
return System.Math.Clamp((int)(System.Math.Round(metres / step) * step), 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) private static double SliderFromFog(int metres)
{ {
double clamped = System.Math.Clamp(metres, Scenery.FogMin, Scenery.FogMax); double clamped = System.Math.Clamp(metres, SceneryWeather.FogMin, SceneryWeather.FogMax);
return 1000.0 * System.Math.Log(clamped / Scenery.FogMin) / return 1000.0 * System.Math.Log(clamped / SceneryWeather.FogMin) /
System.Math.Log((double)Scenery.FogMax / Scenery.FogMin); System.Math.Log((double)SceneryWeather.FogMax / SceneryWeather.FogMin);
} }
private static string FormatFog(int metres) => private static string FormatFog(int metres) =>
@@ -688,7 +691,7 @@ public partial class Scenarios : UserControl
private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e) private void WeatherRestore_OnClick(object? sender, RoutedEventArgs e)
{ {
if (_currentScenery is null) return; if (_currentScenery is null) return;
_currentScenery.RestoreWeather(); _currentScenery.Weather.Restore();
ShowWeather(_currentScenery); ShowWeather(_currentScenery);
} }