diff --git a/.gitignore b/.gitignore
index ff5aab4..e5e78b9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,8 @@
/StarterNG/obj
/.idea
/.vs
+starter/
+
+# Build output of any project in the solution
+[Bb]in/
+[Oo]bj/
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..f17707b
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,14 @@
+{
+ // Use IntelliSense to learn about possible attributes.
+ // Hover to view descriptions of existing attributes.
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+ "version": "0.2.0",
+ "configurations": [
+
+ {
+ "name": "C#: Launch Startup Project",
+ "type": "dotnet",
+ "request": "launch",
+ }
+ ]
+}
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d333a83
--- /dev/null
+++ b/README.md
@@ -0,0 +1,48 @@
+# StarterNG
+
+Launcher for the MaSzyna train simulator, written in C# with Avalonia.
+
+## Building a native (NativeAOT) build
+
+The project publishes as a self-contained NativeAOT binary — no .NET runtime is
+needed on the target machine. The commands below are the same ones CI runs, see
+[`.github/workflows/dotnet.yml`](.github/workflows/dotnet.yml).
+
+Requirements:
+
+- .NET SDK 9
+- Linux: a native toolchain and zlib headers — `sudo apt install clang zlib1g-dev`
+ (Debian/Ubuntu) or `sudo dnf install clang zlib-devel` (Fedora)
+- Windows: the Visual Studio C++ build tools (Desktop development with C++)
+
+Restore for the target runtime first, then publish:
+
+```sh
+dotnet restore StarterNG/StarterNG.csproj -r linux-x64
+
+dotnet publish StarterNG/StarterNG.csproj \
+ -c Release -r linux-x64 --self-contained true --no-restore \
+ -p:PublishAot=true -p:PublishTrimmed=true \
+ -o publish/linux-x64
+```
+
+For a Windows build swap both `linux-x64` occurrences for `win-x64` (and the
+line continuations for PowerShell backticks). NativeAOT does not cross-compile,
+so the Windows package has to be built on Windows and the Linux one on Linux —
+that is why CI uses a `windows-2022` and an `ubuntu-22.04` runner.
+
+The output directory is the whole distributable: the `Starter` executable, the
+Skia/HarfBuzz native libraries and `startercfg/` with the translations and the
+default key bindings. The `.dbg` (or `.pdb`) file next to it is debug symbols and
+can be dropped from a release. Trim/AOT analysis warnings during the publish are
+expected and do not fail the build.
+
+CI builds both runtimes on every push and pull request to `main` and `staging`,
+and on manual dispatch. Pushes additionally replace a rolling pre-release with
+the packaged artifacts — tagged `latest` for `main` and `staging` for `staging`.
+
+## Development build
+
+```sh
+dotnet build StarterNG/StarterNG.csproj
+```
diff --git a/StarterNG.Tests/ExecutableLocatorTests.cs b/StarterNG.Tests/ExecutableLocatorTests.cs
new file mode 100644
index 0000000..bcaed3f
--- /dev/null
+++ b/StarterNG.Tests/ExecutableLocatorTests.cs
@@ -0,0 +1,82 @@
+using StarterNG.Domain.Settings;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class ExecutableLocatorTests
+{
+ private const string Elf = "\u007fELF binary";
+ private const string PortableExecutable = "MZ binary";
+
+ [Fact]
+ public void The_canonical_binary_is_preferred_over_other_candidates()
+ {
+ var files = new InMemoryFileSystem()
+ .WithExecutable(TestInstallation.At("eu07-dev"), Elf)
+ .WithExecutable(TestInstallation.At("eu07"), Elf);
+ var installation = new TestInstallation(files);
+
+ string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
+
+ Assert.Equal(ExeProblem.None, problem);
+ Assert.EndsWith("eu07", resolved);
+ }
+
+ [Fact]
+ public void A_binary_for_the_other_platform_is_reported_as_such()
+ {
+ var files = new InMemoryFileSystem().WithExecutable(TestInstallation.At("eu07"), PortableExecutable);
+ var installation = new TestInstallation(files);
+
+ installation.Executables.Resolve(new SimulatorSettings(), out var problem);
+
+ Assert.Equal(ExeProblem.WrongPlatform, problem);
+ }
+
+ [Fact]
+ public void A_binary_without_the_execute_bit_is_reported_as_such()
+ {
+ var files = new InMemoryFileSystem().WithFile(TestInstallation.At("eu07"), Elf);
+ var installation = new TestInstallation(files);
+
+ installation.Executables.Resolve(new SimulatorSettings(), out var problem);
+
+ Assert.Equal(ExeProblem.NotExecutable, problem);
+ }
+
+ [Fact]
+ public void An_empty_installation_reports_the_canonical_name_as_missing()
+ {
+ var installation = new TestInstallation();
+
+ string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem);
+
+ Assert.Equal(ExeProblem.NotFound, problem);
+ Assert.Equal("eu07", resolved);
+ }
+
+ [Fact]
+ public void A_manually_chosen_path_is_used_as_given()
+ {
+ var files = new InMemoryFileSystem().WithExecutable(TestInstallation.At("eu07-custom"), Elf);
+ var installation = new TestInstallation(files);
+ var settings = new SimulatorSettings { SelectExeAutomatically = false, ExecutablePath = "eu07-custom" };
+
+ string resolved = installation.Executables.Resolve(settings, out var problem);
+
+ Assert.Equal("eu07-custom", resolved);
+ Assert.Equal(ExeProblem.None, problem);
+ }
+
+ [Fact]
+ public void Data_files_beside_the_binary_are_never_offered_as_candidates()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("eu07.ini"), "width 1280")
+ .WithFile(TestInstallation.At("eu07.log"), "log")
+ .WithExecutable(TestInstallation.At("eu07"), Elf);
+ var installation = new TestInstallation(files);
+
+ Assert.Equal(new[] { "eu07" }, installation.Executables.ListCandidates());
+ }
+}
diff --git a/StarterNG.Tests/Fakes/FakeEnvironment.cs b/StarterNG.Tests/Fakes/FakeEnvironment.cs
new file mode 100644
index 0000000..78fc6fd
--- /dev/null
+++ b/StarterNG.Tests/Fakes/FakeEnvironment.cs
@@ -0,0 +1,34 @@
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Tests.Fakes;
+
+/// A host machine described inline: platform, home folder, env vars.
+public sealed class FakeEnvironment : IEnvironment
+{
+ private readonly Dictionary _variables = new(StringComparer.Ordinal);
+
+ public bool IsWindows { get; set; }
+
+ public bool IsLinux { get; set; } = true;
+
+ public bool IsMacOS { get; set; }
+
+ public string BaseDirectory { get; set; } = Path.Combine("game");
+
+ public string? GetVariable(string name) => _variables.TryGetValue(name, out var value) ? value : null;
+
+ public FakeEnvironment With(string name, string value)
+ {
+ _variables[name] = value;
+ return this;
+ }
+
+ /// Switches the fake to Windows, with an APPDATA folder.
+ public FakeEnvironment AsWindows(string appData = "C:/Users/kolejarz/AppData/Roaming")
+ {
+ IsWindows = true;
+ IsLinux = false;
+ IsMacOS = false;
+ return With("APPDATA", appData);
+ }
+}
diff --git a/StarterNG.Tests/Fakes/FakeStrings.cs b/StarterNG.Tests/Fakes/FakeStrings.cs
new file mode 100644
index 0000000..3097b94
--- /dev/null
+++ b/StarterNG.Tests/Fakes/FakeStrings.cs
@@ -0,0 +1,26 @@
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Tests.Fakes;
+
+///
+/// Translation table that echoes keys back, so assertions can name the key that
+/// a screen or check would have shown.
+///
+public sealed class FakeStrings : ILocalizedStrings
+{
+ private readonly Dictionary _overrides = new(StringComparer.Ordinal);
+
+ public string this[string key] => _overrides.TryGetValue(key, out var value) ? value : key;
+
+ public string Opt(string key) => _overrides.TryGetValue(key, out var value) ? value : string.Empty;
+
+ public string CurrentLanguage => "English";
+
+ public string CurrentLangCode => "en";
+
+ public FakeStrings With(string key, string value)
+ {
+ _overrides[key] = value;
+ return this;
+ }
+}
diff --git a/StarterNG.Tests/Fakes/FixedClock.cs b/StarterNG.Tests/Fakes/FixedClock.cs
new file mode 100644
index 0000000..e4a2a0e
--- /dev/null
+++ b/StarterNG.Tests/Fakes/FixedClock.cs
@@ -0,0 +1,16 @@
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Tests.Fakes;
+
+/// A clock frozen at a chosen instant.
+public sealed class FixedClock : IClock
+{
+ public FixedClock(DateTime now)
+ {
+ Now = now;
+ }
+
+ public DateTime Now { get; set; }
+
+ public DateTime UtcNow => Now.ToUniversalTime();
+}
diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
new file mode 100644
index 0000000..2dddd26
--- /dev/null
+++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
@@ -0,0 +1,168 @@
+using System.Text;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Tests.Fakes;
+
+///
+/// An over a dictionary, so a whole MaSzyna
+/// installation can be described inline in a test.
+///
+///
+/// Paths are normalised to forward slashes and compared case-insensitively,
+/// matching how the parsers treat installation paths on both platforms.
+///
+public sealed class InMemoryFileSystem : IFileSystem
+{
+ private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _directories = new(StringComparer.OrdinalIgnoreCase);
+ private readonly HashSet _executable = new(StringComparer.OrdinalIgnoreCase);
+
+ public DateTime LastWriteTimeUtc { get; set; } = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ static InMemoryFileSystem()
+ {
+ // The game's own files are code page 1250; a test that writes Polish text
+ // into one needs the provider registered just as the application does.
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ }
+
+ /// Adds a UTF-8 text file, creating its parent directories.
+ public InMemoryFileSystem WithFile(string path, string contents) =>
+ WithFile(path, contents, Encoding.UTF8);
+
+ /// Adds a text file in a given encoding, as the game's files are stored.
+ public InMemoryFileSystem WithFile(string path, string contents, Encoding encoding)
+ {
+ string key = Normalize(path);
+ _files[key] = encoding.GetBytes(contents);
+ AddParents(key);
+ return this;
+ }
+
+ /// Adds a file in the code page the .scn and .fiz formats use.
+ public InMemoryFileSystem WithLegacyFile(string path, string contents) =>
+ WithFile(path, contents, Encoding.GetEncoding(1250));
+
+ /// Adds an empty directory.
+ public InMemoryFileSystem WithDirectory(string path)
+ {
+ string key = Normalize(path);
+ _directories.Add(key);
+ AddParents(key);
+ return this;
+ }
+
+ public bool FileExists(string path) => _files.ContainsKey(Normalize(path));
+
+ public bool DirectoryExists(string path) => _directories.Contains(Normalize(path));
+
+ public string ReadAllText(string path) => Encoding.UTF8.GetString(Bytes(path));
+
+ public string ReadAllText(string path, Encoding encoding) => encoding.GetString(Bytes(path));
+
+ public string[] ReadAllLines(string path) =>
+ ReadAllText(path).Split('\n').Select(line => line.TrimEnd('\r')).ToArray();
+
+ public Stream OpenRead(string path) => new MemoryStream(Bytes(path), writable: false);
+
+ public void WriteAllText(string path, string contents) => WithFile(path, contents);
+
+ public void WriteAllText(string path, string contents, Encoding encoding)
+ {
+ string key = Normalize(path);
+ _files[key] = encoding.GetBytes(contents);
+ AddParents(key);
+ }
+
+ public void WriteAllLines(string path, IEnumerable lines) =>
+ WithFile(path, string.Join("\n", lines));
+
+ public void WriteAllBytes(string path, byte[] bytes)
+ {
+ string key = Normalize(path);
+ _files[key] = bytes;
+ AddParents(key);
+ }
+
+ public void CreateDirectory(string path) => WithDirectory(path);
+
+ public void DeleteFile(string path) => _files.Remove(Normalize(path));
+
+ public void CopyFile(string sourcePath, string destinationPath, bool overwrite)
+ {
+ string destination = Normalize(destinationPath);
+ if (!overwrite && _files.ContainsKey(destination))
+ throw new IOException($"{destinationPath} already exists");
+ WriteAllBytes(destinationPath, Bytes(sourcePath));
+ }
+
+ public IReadOnlyList GetFiles(string path, string searchPattern = "*")
+ {
+ string prefix = Normalize(path) + "/";
+ var pattern = ToRegex(searchPattern);
+ return _files.Keys
+ .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Where(key => !key[prefix.Length..].Contains('/'))
+ .Where(key => pattern.IsMatch(key[prefix.Length..]))
+ .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public IReadOnlyList GetFilesRecursive(string path, string searchPattern)
+ {
+ string prefix = Normalize(path) + "/";
+ var pattern = ToRegex(searchPattern);
+ return _files.Keys
+ .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Where(key => pattern.IsMatch(key[(key.LastIndexOf('/') + 1)..]))
+ .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public IReadOnlyList GetDirectories(string path)
+ {
+ string prefix = Normalize(path) + "/";
+ return _directories
+ .Where(key => key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ .Where(key => !key[prefix.Length..].Contains('/'))
+ .OrderBy(key => key, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ public DateTime GetLastWriteTimeUtc(string path) => LastWriteTimeUtc;
+
+ /// Files added through carry the execute bit.
+ public bool IsExecutable(string path) => _executable.Contains(Normalize(path));
+
+ public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF")
+ {
+ WithFile(path, contents);
+ _executable.Add(Normalize(path));
+ return this;
+ }
+
+ private byte[] Bytes(string path) =>
+ _files.TryGetValue(Normalize(path), out var bytes)
+ ? bytes
+ : throw new FileNotFoundException(path);
+
+ private void AddParents(string key)
+ {
+ int slash = key.LastIndexOf('/');
+ while (slash > 0)
+ {
+ key = key[..slash];
+ if (!_directories.Add(key))
+ return;
+ slash = key.LastIndexOf('/');
+ }
+ }
+
+ private static string Normalize(string path) =>
+ path.Replace('\\', '/').TrimEnd('/');
+
+ private static System.Text.RegularExpressions.Regex ToRegex(string searchPattern) =>
+ new("^" + System.Text.RegularExpressions.Regex.Escape(searchPattern)
+ .Replace("\\*", ".*").Replace("\\?", ".") + "$",
+ System.Text.RegularExpressions.RegexOptions.IgnoreCase);
+}
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/Fakes/TestInstallation.cs b/StarterNG.Tests/Fakes/TestInstallation.cs
new file mode 100644
index 0000000..724a8d7
--- /dev/null
+++ b/StarterNG.Tests/Fakes/TestInstallation.cs
@@ -0,0 +1,58 @@
+using StarterNG.Application;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Sceneries;
+using StarterNG.Infrastructure.Settings;
+using StarterNG.Infrastructure.Vehicles;
+
+namespace StarterNG.Tests.Fakes;
+
+///
+/// The production object graph wired over an in-memory installation: the same
+/// composition as AppServices, with disk, clock and randomness replaced.
+///
+public sealed class TestInstallation
+{
+ public const string Root = "game";
+
+ public TestInstallation(InMemoryFileSystem? files = null, DateTime? now = null)
+ {
+ Files = files ?? new InMemoryFileSystem();
+ Paths = new GamePaths(Root);
+ Clock = new FixedClock(now ?? new DateTime(2026, 1, 1, 12, 0, 0));
+ Random = new StubRandom();
+ Log = new FileDiagnosticsLog(Files, Clock, Paths);
+ Strings = new FakeStrings();
+
+ MiniTextures = new MiniTextureIndex(Files, Paths);
+ Physics = new FizPhysicsRepository(Files, Paths, Log);
+ Vehicles = new TexturesTxtVehicleRepository(Files, Paths, Log, new TexturesTxtParser());
+ Sceneries = new SceneryRepository(Files, Paths, Log, new SceneryParser(Clock, Random));
+ MissingAssets = new MissingAssetScanner(Files, Paths);
+ Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, Log);
+
+ Environment = new FakeEnvironment();
+ SettingsPaths = new SettingsPaths(Environment, Paths);
+ Executables = new ExecutableLocator(Files, Paths, Environment, Log);
+ SettingsStore = new SettingsStore(Files, Clock, Log, SettingsPaths, new SettingsSerializer(), Executables);
+ }
+
+ public InMemoryFileSystem Files { get; }
+ public GamePaths Paths { get; }
+ public FixedClock Clock { get; }
+ public StubRandom Random { get; }
+ public FileDiagnosticsLog Log { get; }
+ public FakeStrings Strings { get; }
+ public MiniTextureIndex MiniTextures { get; }
+ public FizPhysicsRepository Physics { get; }
+ public TexturesTxtVehicleRepository Vehicles { get; }
+ public SceneryRepository Sceneries { get; }
+ public MissingAssetScanner MissingAssets { get; }
+ public GameLibrary Library { get; }
+ public FakeEnvironment Environment { get; }
+ public SettingsPaths SettingsPaths { get; }
+ public ExecutableLocator Executables { get; }
+ public SettingsStore SettingsStore { get; }
+
+ /// Path of a file inside the fake installation.
+ public static string At(params string[] segments) => Path.Combine(new[] { Root }.Concat(segments).ToArray());
+}
diff --git a/StarterNG.Tests/FileDiagnosticsLogTests.cs b/StarterNG.Tests/FileDiagnosticsLogTests.cs
new file mode 100644
index 0000000..aa3f629
--- /dev/null
+++ b/StarterNG.Tests/FileDiagnosticsLogTests.cs
@@ -0,0 +1,45 @@
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class FileDiagnosticsLogTests
+{
+ private static FileDiagnosticsLog Build(out InMemoryFileSystem files)
+ {
+ files = new InMemoryFileSystem();
+ return new FileDiagnosticsLog(files, new FixedClock(new DateTime(2026, 3, 7, 14, 5, 0)),
+ new GamePaths("game"));
+ }
+
+ [Fact]
+ public void Log_timestamps_each_line_from_the_clock()
+ {
+ var log = Build(out _);
+
+ log.Log("scenery loaded");
+
+ Assert.Equal("2026-03-07 14:05:00 scenery loaded", log.Text);
+ }
+
+ [Fact]
+ public void Exceptions_are_logged_with_their_context_and_type()
+ {
+ var log = Build(out _);
+
+ log.Log("scenery/foo.scn", new InvalidOperationException("bad node"));
+
+ Assert.Contains("scenery/foo.scn: InvalidOperationException: bad node", log.Text);
+ }
+
+ [Fact]
+ public void Clear_drops_everything_logged_so_far()
+ {
+ var log = Build(out _);
+ log.Log("first");
+
+ log.Clear();
+
+ Assert.Equal(string.Empty, log.Text);
+ }
+}
diff --git a/StarterNG.Tests/GameLibraryTests.cs b/StarterNG.Tests/GameLibraryTests.cs
new file mode 100644
index 0000000..1efe8a5
--- /dev/null
+++ b/StarterNG.Tests/GameLibraryTests.cs
@@ -0,0 +1,75 @@
+using StarterNG.Application;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class GameLibraryTests
+{
+ private static TestInstallation Complete() =>
+ new(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"), "ep07-135.mat=ep07.e3d,ep07")
+ .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
+ .WithFile(TestInstallation.At("textures", "mini", "ep07.bmp"), "bitmap"));
+
+ [Fact]
+ public void Loading_reads_both_the_rolling_stock_and_the_scenarios()
+ {
+ var library = Complete().Library;
+
+ library.Load();
+
+ Assert.True(library.Loaded);
+ Assert.Single(library.Vehicles.Textures);
+ Assert.Equal("Katowice", Assert.Single(library.Sceneries).Name);
+ }
+
+ [Fact]
+ public void Loading_reports_progress_through_to_done()
+ {
+ var phases = new List();
+ var progress = new Progress(status => { lock (phases) phases.Add(status.Phase); });
+
+ Complete().Library.Load(progress);
+
+ SpinWait.SpinUntil(() => { lock (phases) return phases.Contains(LoadPhase.Done); }, TimeSpan.FromSeconds(2));
+ lock (phases)
+ {
+ Assert.Contains(LoadPhase.Vehicles, phases);
+ Assert.Contains(LoadPhase.Done, phases);
+ }
+ }
+
+ [Fact]
+ public void Loading_twice_does_not_duplicate_the_scenarios()
+ {
+ var library = Complete().Library;
+
+ library.Load();
+ library.Load();
+
+ Assert.Single(library.Sceneries);
+ }
+
+ [Fact]
+ public void Reloading_picks_up_a_scenery_added_after_startup()
+ {
+ var installation = Complete();
+ var library = installation.Library;
+ library.Load();
+
+ installation.Files.WithFile(TestInstallation.At("scenery", "gdansk.scn"), "//$n Gdańsk");
+ library.ReloadSceneries();
+
+ Assert.Equal(2, library.Sceneries.Count);
+ }
+
+ [Fact]
+ public void An_installation_with_no_rolling_stock_is_logged()
+ {
+ var installation = new TestInstallation();
+
+ installation.Library.Load();
+
+ Assert.Contains("textures.txt", installation.Log.Text);
+ }
+}
diff --git a/StarterNG.Tests/GamePathsTests.cs b/StarterNG.Tests/GamePathsTests.cs
new file mode 100644
index 0000000..ca0930a
--- /dev/null
+++ b/StarterNG.Tests/GamePathsTests.cs
@@ -0,0 +1,26 @@
+using StarterNG.Infrastructure.Adapters;
+
+namespace StarterNG.Tests;
+
+public class GamePathsTests
+{
+ [Fact]
+ public void Wellknown_folders_hang_off_the_installation_root()
+ {
+ var paths = new GamePaths(Path.Combine("opt", "maszyna"));
+
+ Assert.Equal(Path.Combine("opt", "maszyna", "scenery"), paths.Scenery);
+ Assert.Equal(Path.Combine("opt", "maszyna", "dynamic"), paths.Dynamic);
+ Assert.Equal(Path.Combine("opt", "maszyna", "textures", "mini"), paths.MiniTextures);
+ Assert.Equal(Path.Combine("opt", "maszyna", "starter", "bledy.txt"), paths.DiagnosticsLog);
+ }
+
+ [Fact]
+ public void FromRoot_joins_segments_below_the_root()
+ {
+ var paths = new GamePaths("game");
+
+ Assert.Equal(Path.Combine("game", "data", "load_weights.txt"),
+ paths.FromRoot("data", "load_weights.txt"));
+ }
+}
diff --git a/StarterNG.Tests/InstallationCheckTests.cs b/StarterNG.Tests/InstallationCheckTests.cs
new file mode 100644
index 0000000..ffe1215
--- /dev/null
+++ b/StarterNG.Tests/InstallationCheckTests.cs
@@ -0,0 +1,43 @@
+using StarterNG.Infrastructure;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class InstallationCheckTests
+{
+ [Fact]
+ public void An_empty_folder_reports_every_missing_game_directory()
+ {
+ var installation = new TestInstallation();
+ var strings = installation.Strings.With("FaultNoDir", "missing {0}");
+ var check = new InstallationCheck(installation.Paths, installation.Files, strings,
+ installation.Physics, installation.Library, installation.SettingsStore);
+
+ var faults = check.Run();
+
+ Assert.Contains("missing /dynamic", faults);
+ Assert.Contains("missing /scenery", faults);
+ Assert.Contains("FaultNoWeights", faults);
+ }
+
+ [Fact]
+ public void A_complete_installation_reports_no_missing_directories()
+ {
+ var files = new InMemoryFileSystem()
+ .WithDirectory(Path.Combine("game", "dynamic"))
+ .WithDirectory(Path.Combine("game", "sounds"))
+ .WithDirectory(Path.Combine("game", "models"))
+ .WithDirectory(Path.Combine("game", "scenery"))
+ .WithDirectory(Path.Combine("game", "textures"))
+ .WithFile(Path.Combine("game", "data", "load_weights.txt"), "");
+ var installation = new TestInstallation(files);
+ var strings = installation.Strings.With("FaultNoDir", "missing {0}");
+
+ var faults = new InstallationCheck(installation.Paths, files, strings,
+ installation.Physics, installation.Library, installation.SettingsStore).Run();
+
+ Assert.DoesNotContain(faults, fault => fault.StartsWith("missing "));
+ Assert.DoesNotContain("FaultNoWeights", faults);
+ }
+}
diff --git a/StarterNG.Tests/LegacyTextTests.cs b/StarterNG.Tests/LegacyTextTests.cs
new file mode 100644
index 0000000..9e48eee
--- /dev/null
+++ b/StarterNG.Tests/LegacyTextTests.cs
@@ -0,0 +1,38 @@
+using StarterNG.Infrastructure.Adapters;
+
+namespace StarterNG.Tests;
+
+public class LegacyTextTests
+{
+ [Fact]
+ public void The_game_code_page_resolves_without_anyone_registering_it_first()
+ {
+ // Regression guard: when this falls back, every Polish character in a
+ // scenery description comes out as Latin-1 mojibake ("Skład" as "Sk³ad").
+ Assert.False(LegacyText.IsFallback);
+ Assert.Equal(1250, LegacyText.CodePage1250.CodePage);
+ }
+
+ [Fact]
+ public void Polish_characters_survive_a_round_trip_through_it()
+ {
+ const string text = "Skład wygaszony pod wjazdowym. Strzebiń - Woźniki Śl.";
+
+ byte[] bytes = LegacyText.CodePage1250.GetBytes(text);
+
+ Assert.Equal(text, LegacyText.CodePage1250.GetString(bytes));
+ }
+
+ [Fact]
+ public void A_scenery_written_in_the_game_code_page_reads_back_intact()
+ {
+ var files = new StarterNG.Tests.Fakes.InMemoryFileSystem()
+ .WithLegacyFile(StarterNG.Tests.Fakes.TestInstallation.At("scenery", "td.scn"),
+ "//$n Strzebiń - Woźniki Śl. '92\n//$d Skład wygaszony pod wjazdowym.");
+
+ var scenery = Assert.Single(new StarterNG.Tests.Fakes.TestInstallation(files).Sceneries.LoadAll());
+
+ Assert.Equal("Strzebiń - Woźniki Śl. '92", scenery.Name);
+ Assert.Equal("Skład wygaszony pod wjazdowym.", scenery.Description);
+ }
+}
diff --git a/StarterNG.Tests/LoadWeightsTests.cs b/StarterNG.Tests/LoadWeightsTests.cs
new file mode 100644
index 0000000..ae6e520
--- /dev/null
+++ b/StarterNG.Tests/LoadWeightsTests.cs
@@ -0,0 +1,41 @@
+using StarterNG.Classes;
+using StarterNG.Domain.Vehicles;
+using StarterNG.Infrastructure.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class LoadWeightsTests
+{
+ private static LoadWeightsRepository Repository(InMemoryFileSystem files)
+ {
+ var installation = new TestInstallation(files);
+ return new LoadWeightsRepository(files, installation.Paths, installation.Log);
+ }
+
+ [Fact]
+ public void Weights_are_read_from_the_pairs_in_the_file()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("data", "load_weights.txt"),
+ "{\n coal : 850 # węgiel\n sand : 1600\n}");
+
+ var table = Repository(files).Table;
+
+ Assert.Equal(850, table.WeightOf("coal"));
+ Assert.Equal(1600, table.WeightOf("SAND"));
+ }
+
+ [Fact]
+ public void An_unknown_cargo_falls_back_to_the_default_weight()
+ {
+ Assert.Equal(LoadWeightsTable.DefaultWeight,
+ Repository(new InMemoryFileSystem()).Table.WeightOf("nieznany"));
+ }
+
+ [Fact]
+ public void The_pantograph_pseudo_load_weighs_nothing()
+ {
+ Assert.Equal(0, new LoadWeightsTable().WeightOf(Dynamic.PantState));
+ }
+}
diff --git a/StarterNG.Tests/MiniTextureIndexTests.cs b/StarterNG.Tests/MiniTextureIndexTests.cs
new file mode 100644
index 0000000..9133c66
--- /dev/null
+++ b/StarterNG.Tests/MiniTextureIndexTests.cs
@@ -0,0 +1,55 @@
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class MiniTextureIndexTests
+{
+ private static MiniTextureIndex Build(params string[] thumbnails)
+ {
+ var files = new InMemoryFileSystem();
+ foreach (string name in thumbnails)
+ files.WithFile(TestInstallation.At("textures", "mini", name + ".bmp"), "bitmap");
+
+ return new MiniTextureIndex(files, new GamePaths(TestInstallation.Root));
+ }
+
+ [Fact]
+ public void A_thumbnail_is_found_by_name_whatever_its_case()
+ {
+ var index = Build("ep07", "other");
+
+ Assert.True(index.Has("EP07"));
+ Assert.EndsWith("ep07.bmp", index.PathFor("ep07"));
+ }
+
+ [Fact]
+ public void A_vehicle_naming_an_unknown_thumbnail_gets_other_bmp()
+ {
+ var index = Build("ep07", "other");
+
+ Assert.False(index.Has("nie-ma-takiej"));
+ Assert.EndsWith("other.bmp", index.PathFor("nie-ma-takiej"));
+ }
+
+ [Fact]
+ public void Without_other_bmp_there_is_nothing_to_fall_back_to()
+ {
+ var index = Build("ep07");
+
+ // The views draw their own stand-in wagon from here on; see
+ // MiniTextures.MissingVisual.
+ Assert.Null(index.PathFor("nie-ma-takiej"));
+ Assert.Null(index.FallbackPath);
+ }
+
+ [Fact]
+ public void An_installation_with_no_thumbnails_at_all_resolves_nothing()
+ {
+ var index = Build();
+
+ Assert.False(index.Has("ep07"));
+ Assert.Null(index.PathFor("ep07"));
+ }
+}
diff --git a/StarterNG.Tests/PhysicsAndAssetsTests.cs b/StarterNG.Tests/PhysicsAndAssetsTests.cs
new file mode 100644
index 0000000..1d38874
--- /dev/null
+++ b/StarterNG.Tests/PhysicsAndAssetsTests.cs
@@ -0,0 +1,128 @@
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class PhysicsAndAssetsTests
+{
+ private static string Fiz(string maker, string vehicle, string name) =>
+ TestInstallation.At("dynamic", maker, vehicle, name + ".fiz");
+
+ [Fact]
+ public void A_fiz_yields_the_properties_the_starter_shows()
+ {
+ const string fiz = """
+ Param: M=80000 Vmax=125
+ Dimensions: L=15.9
+ Load: LoadAccepted=Coal MaxLoad=40
+ Engine: EngineType=DieselElectric
+ """;
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(Fiz("pkp", "sm42", "sm42"), fiz));
+
+ var physics = installation.Physics.For("pkp/sm42/", "sm42.e3d");
+
+ Assert.NotNull(physics);
+ Assert.Equal(80000, physics!.Mass);
+ Assert.Equal(125, physics.VMax);
+ Assert.Equal(15.9, physics.Length);
+ Assert.Equal("Coal", physics.LoadAccepted);
+ Assert.Equal(40, physics.MaxLoad);
+ Assert.True(physics.IsDieselEngine);
+ }
+
+ [Fact]
+ public void A_vehicle_declaring_one_coupler_gets_the_same_at_both_ends()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(Fiz("pkp", "111a", "111a"), "BuffCoupl1: AllowedFlag=3 ControlType=screw"));
+
+ var physics = installation.Physics.For("pkp/111a/", "111a")!;
+
+ Assert.Equal(3, physics.AllowedFlagA);
+ Assert.Equal(3, physics.AllowedFlagB);
+ Assert.Equal("screw", physics.ControlTypeB);
+ }
+
+ [Fact]
+ public void A_negative_coupler_flag_is_encoded_as_disabled()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(Fiz("pkp", "111a", "111a"), "BuffCoupl1: AllowedFlag=-3"));
+
+ Assert.Equal(131, installation.Physics.For("pkp/111a/", "111a")!.AllowedFlagA);
+ }
+
+ [Fact]
+ public void An_include_is_followed_and_its_parameters_substituted()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(Fiz("pkp", "ep07", "ep07"), "include common.fiz 84000 end")
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "common.fiz"), "Param: M=(1) Vmax=125"));
+
+ var physics = installation.Physics.For("pkp/ep07/", "ep07")!;
+
+ Assert.Equal(84000, physics.Mass);
+ Assert.Equal(125, physics.VMax);
+ }
+
+ [Fact]
+ public void A_dumb_variant_stands_in_when_the_model_has_no_fiz_of_its_own()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(Fiz("pkp", "ep07", "ep07dumb"), "Param: M=1000"));
+
+ Assert.NotNull(installation.Physics.For("pkp/ep07/", "ep07"));
+ }
+
+ [Fact]
+ public void An_unknown_model_has_no_physics_and_an_empty_installation_indexes_nothing()
+ {
+ var installation = new TestInstallation();
+
+ Assert.Null(installation.Physics.For("pkp/x/", "nieznany"));
+ installation.Physics.Preload();
+ Assert.Equal(0, installation.Physics.IndexedCount);
+ }
+
+ [Fact]
+ public void Thumbnails_are_looked_up_by_name_with_a_fallback_to_other()
+ {
+ var installation = new TestInstallation(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("textures", "mini", "ep07.bmp"), "bitmap")
+ .WithFile(TestInstallation.At("textures", "mini", "other.bmp"), "bitmap"));
+
+ Assert.True(installation.MiniTextures.Has("EP07"));
+ Assert.False(installation.MiniTextures.Has("nieznany"));
+ Assert.EndsWith("ep07.bmp", installation.MiniTextures.PathFor("ep07"));
+ Assert.EndsWith("other.bmp", installation.MiniTextures.PathFor("nieznany"));
+ }
+
+ [Fact]
+ public void A_livery_whose_skin_and_model_are_absent_is_reported()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"),
+ "ep07-135.mat=ep07.e3d,ep07");
+ var installation = new TestInstallation(files);
+ installation.Vehicles.Load(installation.Library.Vehicles);
+
+ var missing = installation.MissingAssets.Scan(installation.Library.Vehicles);
+
+ Assert.Contains(missing, line => line.Contains("no file:") && line.Contains("ep07-135"));
+ Assert.Contains(missing, line => line.Contains("no model:") && line.Contains("ep07.e3d"));
+ }
+
+ [Fact]
+ public void A_skin_present_under_an_accepted_extension_is_not_reported()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "textures.txt"),
+ "ep07-135.mat=ep07.e3d,ep07")
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07-135.bmp"), "bitmap")
+ .WithFile(TestInstallation.At("dynamic", "pkp", "ep07", "ep07.t3d"), "model");
+ var installation = new TestInstallation(files);
+ installation.Vehicles.Load(installation.Library.Vehicles);
+
+ Assert.Empty(installation.MissingAssets.Scan(installation.Library.Vehicles));
+ }
+}
diff --git a/StarterNG.Tests/ScenarioPresentationTests.cs b/StarterNG.Tests/ScenarioPresentationTests.cs
new file mode 100644
index 0000000..b3f9ce5
--- /dev/null
+++ b/StarterNG.Tests/ScenarioPresentationTests.cs
@@ -0,0 +1,167 @@
+using StarterNG.Domain.Sceneries;
+using StarterNG.Infrastructure.Sceneries;
+using StarterNG.Presentation.Scenarios;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class FogScaleTests
+{
+ [Fact]
+ public void The_slider_ends_land_on_the_supported_visibility_range()
+ {
+ Assert.Equal(SceneryWeather.FogMin, FogScale.ToMetres(0));
+ Assert.Equal(SceneryWeather.FogMax, FogScale.ToMetres(FogScale.SliderRange));
+ }
+
+ [Fact]
+ public void A_distance_survives_a_round_trip_through_the_slider()
+ {
+ foreach (int metres in new[] { 50, 200, 1500, 8000 })
+ Assert.Equal(metres, FogScale.ToMetres(FogScale.ToSlider(metres)));
+ }
+
+ [Fact]
+ public void Distances_snap_to_a_step_that_grows_with_the_range()
+ {
+ Assert.Equal(0, FogScale.ToMetres(200) % 5);
+ Assert.Equal(0, FogScale.ToMetres(700) % 100);
+ }
+
+ [Fact]
+ public void Metres_below_a_kilometre_kilometres_above_it()
+ {
+ var culture = System.Globalization.CultureInfo.InvariantCulture;
+
+ Assert.Equal("250 m", FogScale.Format(250, culture));
+ Assert.Equal("2.5 km", FogScale.Format(2500, culture));
+ }
+}
+
+public class SeasonDatesTests
+{
+ private static SeasonDates On(DateTime now) => new(new FixedClock(now));
+
+ [Fact]
+ public void A_stored_day_of_year_becomes_that_day_in_the_current_year()
+ {
+ var date = On(new DateTime(2026, 7, 1)).DateOf(100);
+
+ Assert.Equal(2026, date.Year);
+ Assert.Equal(100, date.DayOfYear);
+ }
+
+ [Fact]
+ public void Day_zero_means_today()
+ {
+ var now = new DateTime(2026, 5, 4);
+
+ Assert.Equal(now.DayOfYear, On(now).DateOf(0).DayOfYear);
+ }
+
+ [Fact]
+ public void The_last_day_of_a_leap_year_stays_selectable()
+ {
+ Assert.Equal(366, On(new DateTime(2028, 1, 1)).DateOf(366).DayOfYear);
+ Assert.Equal(365, On(new DateTime(2026, 1, 1)).DateOf(400).DayOfYear);
+ }
+}
+
+public class SceneryTreeBuilderTests
+{
+ private static (SceneryTreeBuilder Builder, List Sceneries) Build(params string[] contents)
+ {
+ var files = new InMemoryFileSystem();
+ for (int i = 0; i < contents.Length; i++)
+ files.WithLegacyFile(TestInstallation.At("scenery", $"s{i}.scn"), contents[i]);
+
+ var installation = new TestInstallation(files);
+ var sceneries = installation.Sceneries.LoadAll().ToList();
+ return (new SceneryTreeBuilder(new SceneryTranslations(files, installation.Log)), sceneries);
+ }
+
+ [Fact]
+ public void Scenarios_declaring_a_group_are_gathered_under_it()
+ {
+ var (builder, sceneries) = Build("//$l Śląsk", "//$l Śląsk", "//$n Bez grupy");
+
+ var nodes = builder.Build(sceneries, includeArchival: true, "en");
+
+ var group = Assert.Single(nodes, node => node.IsGroup);
+ Assert.Equal("Śląsk", group.Label);
+ Assert.Equal(2, group.Children.Count);
+ Assert.Single(nodes, node => !node.IsGroup);
+ }
+
+ [Fact]
+ public void Archival_scenarios_are_left_out_unless_asked_for()
+ {
+ var (builder, sceneries) = Build("//$a", "//$n Zwykla");
+
+ Assert.Single(builder.Build(sceneries, includeArchival: false, "en"));
+ Assert.Equal(2, builder.Build(sceneries, includeArchival: true, "en").Count);
+ }
+
+ [Fact]
+ public void Nodes_carry_the_index_of_the_scenery_they_stand_for()
+ {
+ var (builder, sceneries) = Build("//$n Pierwsza", "//$n Druga");
+
+ var nodes = builder.Build(sceneries, includeArchival: true, "en");
+
+ foreach (var node in nodes)
+ Assert.Equal(node.Label, sceneries[node.SceneryIndex].DisplayName);
+ }
+
+ [Fact]
+ public void Both_levels_are_sorted_by_name()
+ {
+ var (builder, sceneries) = Build("//$l Zet", "//$l Alfa", "//$l Alfa");
+
+ var nodes = builder.Build(sceneries, includeArchival: true, "en");
+
+ Assert.Equal(new[] { "Alfa", "Zet" }, nodes.Select(node => node.Label));
+ Assert.Equal(nodes[0].Children.Select(child => child.Label).OrderBy(label => label),
+ nodes[0].Children.Select(child => child.Label));
+ }
+}
+
+public class TimetableLocatorTests
+{
+ [Fact]
+ public void A_timetable_is_found_in_the_shared_folder_first()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
+ .WithFile(TestInstallation.At("timetables", "r1.txt"), "rozkład")
+ .WithFile(TestInstallation.At("scenery", "r1.txt"), "rozkład obok scenerii");
+ var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
+
+ Assert.Equal(TestInstallation.At("timetables", "r1.txt"),
+ new TimetableLocator(files).Resolve(scenery, "r1"));
+ }
+
+ [Fact]
+ public void A_name_already_carrying_its_extension_is_found_too()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice")
+ .WithFile(TestInstallation.At("timetables", "r1.txt"), "rozkład");
+ var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
+
+ Assert.NotNull(new TimetableLocator(files).Resolve(scenery, "r1.txt"));
+ }
+
+ [Fact]
+ public void The_formats_none_and_a_missing_file_both_resolve_to_nothing()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$n Katowice");
+ var scenery = Assert.Single(new TestInstallation(files).Sceneries.LoadAll());
+ var locator = new TimetableLocator(files);
+
+ Assert.Null(locator.Resolve(scenery, "none"));
+ Assert.Null(locator.Resolve(scenery, "nieistniejacy"));
+ Assert.Null(locator.Resolve(scenery, null));
+ }
+}
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/SceneryTranslationsTests.cs b/StarterNG.Tests/SceneryTranslationsTests.cs
new file mode 100644
index 0000000..a2d422b
--- /dev/null
+++ b/StarterNG.Tests/SceneryTranslationsTests.cs
@@ -0,0 +1,52 @@
+using StarterNG.Infrastructure.Sceneries;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class SceneryTranslationsTests
+{
+ private static string I18n(string file) => Path.Combine(Path.GetFullPath(TestInstallation.At("scenery")),
+ "i18n", file);
+
+ [Fact]
+ public void An_at_reference_is_resolved_against_the_sceneries_own_table()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$d @mission.brief")
+ .WithFile(I18n("kato_en.json"), """{ "mission": { "brief": "Take the stopper to Katowice" } }""");
+ var installation = new TestInstallation(files);
+ var scenery = Assert.Single(installation.Sceneries.LoadAll());
+ var translations = new SceneryTranslations(files, installation.Log);
+
+ translations.LoadFor(scenery, "en");
+
+ Assert.Equal("Take the stopper to Katowice", scenery.LocalizedDescription(translations));
+ }
+
+ [Fact]
+ public void The_users_language_is_layered_over_english()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("scenery", "kato.scn"), "//$d @brief")
+ .WithFile(I18n("kato_en.json"), """{ "brief": "English" }""")
+ .WithFile(I18n("kato_pl.json"), """{ "brief": "Polski" }""");
+ var installation = new TestInstallation(files);
+ var scenery = Assert.Single(installation.Sceneries.LoadAll());
+ var translations = new SceneryTranslations(files, installation.Log);
+
+ translations.LoadFor(scenery, "Polski");
+
+ Assert.Equal("Polski", scenery.LocalizedDescription(translations));
+ }
+
+ [Fact]
+ public void Literal_text_and_unknown_keys_are_left_alone()
+ {
+ var installation = new TestInstallation();
+ var translations = new SceneryTranslations(installation.Files, installation.Log);
+
+ Assert.Equal("zwykły opis", translations.Translate("zwykły opis"));
+ Assert.Equal("@nieznany.klucz", translations.Translate("@nieznany.klucz"));
+ Assert.Equal("", translations.Translate(null));
+ }
+}
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.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs
new file mode 100644
index 0000000..ba870a9
--- /dev/null
+++ b/StarterNG.Tests/SettingsStoreTests.cs
@@ -0,0 +1,208 @@
+using StarterNG.Domain.Settings;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class SettingsStoreTests
+{
+ private const string UserIni = "/home/kolejarz/.config/MaSzyna/eu07.ini";
+
+ private static TestInstallation WithHome(InMemoryFileSystem? files = null)
+ {
+ var installation = new TestInstallation(files);
+ installation.Environment.With("HOME", "/home/kolejarz");
+ return installation;
+ }
+
+ [Fact]
+ public void Settings_are_read_from_the_users_ini()
+ {
+ var installation = WithHome(new InMemoryFileSystem()
+ .WithFile(UserIni, "width 1920\nheight 1080\nfullscreen yes\nlang pl\n"));
+
+ installation.SettingsStore.Load();
+
+ var settings = installation.SettingsStore.Settings;
+ Assert.Equal(1920, settings.Width);
+ Assert.Equal(1080, settings.Height);
+ Assert.True(settings.Fullscreen);
+ Assert.Equal("pl", settings.LanguageCode);
+ Assert.True(settings.LanguageWasSet);
+ Assert.Equal(UserIni, installation.SettingsStore.LoadedFrom);
+ }
+
+ [Fact]
+ public void A_language_the_starter_does_not_ship_survives_a_round_trip()
+ {
+ // A user-supplied translation must reach the simulator as written; folding
+ // every unknown code into "en" is what forced eu07.exe into English.
+ var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "lang zh\n"));
+ installation.SettingsStore.Load();
+
+ Assert.Equal("zh", installation.SettingsStore.Settings.LanguageCode);
+
+ installation.SettingsStore.Save();
+
+ Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni));
+ }
+
+ [Fact]
+ public void A_language_chosen_in_the_picker_is_written_as_its_own_code()
+ {
+ var installation = WithHome();
+ installation.SettingsStore.Load();
+ installation.SettingsStore.Settings.Language = "中文";
+ installation.SettingsStore.Settings.LanguageCode = "zh";
+
+ installation.SettingsStore.Save();
+
+ Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni));
+ }
+
+ [Fact]
+ public void With_no_language_recorded_the_file_still_names_one()
+ {
+ var installation = WithHome();
+ installation.SettingsStore.Load();
+
+ installation.SettingsStore.Save();
+
+ Assert.Contains("lang en", installation.Files.ReadAllText(UserIni));
+ }
+
+ [Fact]
+ public void The_language_code_is_written_in_lower_case()
+ {
+ var installation = WithHome();
+ installation.SettingsStore.Load();
+ installation.SettingsStore.Settings.LanguageCode = "ZH";
+
+ installation.SettingsStore.Save();
+
+ Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni));
+ }
+
+ [Fact]
+ public void The_consist_state_override_keeps_the_key_the_old_starter_used()
+ {
+ var installation = WithHome(new InMemoryFileSystem()
+ .WithFile(UserIni, "starter.batterydefault 2\n"));
+
+ installation.SettingsStore.Load();
+ Assert.Equal(ConsistReadyOverride.AlwaysReady, installation.SettingsStore.Settings.ReadyOverride);
+
+ installation.SettingsStore.Settings.ReadyOverride = ConsistReadyOverride.AlwaysCold;
+ installation.SettingsStore.Save();
+
+ Assert.Contains("starter.batterydefault 1", installation.Files.ReadAllText(UserIni));
+ }
+
+ [Fact]
+ public void An_out_of_range_override_falls_back_to_leaving_the_scenery_alone()
+ {
+ var installation = WithHome(new InMemoryFileSystem()
+ .WithFile(UserIni, "starter.batterydefault 9\n"));
+
+ installation.SettingsStore.Load();
+
+ Assert.Equal(ConsistReadyOverride.Scenery, installation.SettingsStore.Settings.ReadyOverride);
+ }
+
+ [Fact]
+ public void A_first_run_falls_back_to_the_ini_shipped_with_the_installation()
+ {
+ var installation = WithHome(new InMemoryFileSystem()
+ .WithFile(TestInstallation.At("eu07.ini"), "width 800\n"));
+
+ installation.SettingsStore.Load();
+
+ Assert.Equal(800, installation.SettingsStore.Settings.Width);
+ Assert.Equal(TestInstallation.At("eu07.ini"), installation.SettingsStore.LoadedFrom);
+ // Saving still goes to the user's own file, never back into the installation.
+ Assert.Equal(UserIni, installation.SettingsStore.SavePath);
+ }
+
+ [Fact]
+ public void With_no_ini_at_all_the_defaults_stand()
+ {
+ var installation = WithHome();
+
+ installation.SettingsStore.Load();
+
+ Assert.Equal(1280, installation.SettingsStore.Settings.Width);
+ Assert.False(installation.SettingsStore.Settings.LanguageWasSet);
+ }
+
+ [Fact]
+ public void A_save_round_trips_the_settings()
+ {
+ var installation = WithHome();
+ installation.SettingsStore.Load();
+ installation.SettingsStore.Settings.Width = 2560;
+ installation.SettingsStore.Settings.FieldOfView = 60;
+ installation.SettingsStore.Save();
+
+ var reopened = WithHome(installation.Files);
+ reopened.SettingsStore.Load();
+
+ Assert.Equal(2560, reopened.SettingsStore.Settings.Width);
+ Assert.Equal(60, reopened.SettingsStore.Settings.FieldOfView);
+ }
+
+ [Fact]
+ public void Keys_the_starter_does_not_manage_survive_a_round_trip()
+ {
+ var installation = WithHome(new InMemoryFileSystem()
+ .WithFile(UserIni, "width 1280\nsomethingexotic 42\n"));
+ installation.SettingsStore.Load();
+
+ installation.SettingsStore.Save();
+
+ Assert.Contains("somethingexotic 42", installation.Files.ReadAllText(UserIni));
+ }
+
+ [Fact]
+ public void A_registered_screen_is_asked_for_its_edits_before_a_save()
+ {
+ var installation = WithHome();
+ installation.SettingsStore.Load();
+ installation.SettingsStore.RegisterCapture(new WidthCapture(3840));
+
+ installation.SettingsStore.CaptureAndSave();
+
+ Assert.Equal(3840, installation.SettingsStore.Settings.Width);
+ }
+
+ [Fact]
+ public void An_ini_written_by_someone_else_is_noticed()
+ {
+ var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "width 1280\n"));
+ installation.SettingsStore.Load();
+
+ Assert.False(installation.SettingsStore.IsConfigNewerOnDisk());
+
+ installation.Files.LastWriteTimeUtc = installation.Files.LastWriteTimeUtc.AddMinutes(5);
+
+ Assert.True(installation.SettingsStore.IsConfigNewerOnDisk());
+ }
+
+ [Fact]
+ public void On_windows_the_ini_lives_under_appdata()
+ {
+ var installation = new TestInstallation();
+ installation.Environment.AsWindows();
+
+ Assert.Equal(Path.Combine("C:/Users/kolejarz/AppData/Roaming", "MaSzyna", "eu07.ini"),
+ installation.SettingsPaths.UserConfigPath());
+ }
+
+ /// Stands in for the settings screen holding an unsaved edit.
+ private sealed class WidthCapture : StarterNG.Application.ISettingsCapture
+ {
+ private readonly int _width;
+
+ public WidthCapture(int width) => _width = width;
+
+ public void CaptureInto(SimulatorSettings settings) => settings.Width = _width;
+ }
+}
diff --git a/StarterNG.Tests/StarterNG.Tests.csproj b/StarterNG.Tests/StarterNG.Tests.csproj
new file mode 100644
index 0000000..165de2d
--- /dev/null
+++ b/StarterNG.Tests/StarterNG.Tests.csproj
@@ -0,0 +1,25 @@
+
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/StarterNG.Tests/TexturesTxtParserTests.cs b/StarterNG.Tests/TexturesTxtParserTests.cs
new file mode 100644
index 0000000..70e74f0
--- /dev/null
+++ b/StarterNG.Tests/TexturesTxtParserTests.cs
@@ -0,0 +1,103 @@
+using StarterNG.Infrastructure.Vehicles;
+
+namespace StarterNG.Tests;
+
+public class TexturesTxtParserTests
+{
+ private static readonly TexturesTxtParser Parser = new();
+
+ [Fact]
+ public void A_livery_line_yields_a_texture_with_its_model_and_thumbnail()
+ {
+ var entry = Parser.Parse("pkp/ep07/", new[] { "ep07-135.mat=ep07.e3d,ep07" });
+
+ var texture = Assert.Single(entry!.Textures);
+ Assert.Equal("ep07-135", texture.Skinfile);
+ Assert.Equal("pkp/ep07/", texture.Directory);
+ Assert.Equal("ep07.e3d", texture.Model);
+ Assert.Equal("ep07", texture.MiniRef);
+ Assert.False(texture.Wreck);
+ }
+
+ [Fact]
+ public void The_trailing_comment_is_read_as_the_credit_line()
+ {
+ var entry = Parser.Parse("pkp/ep07/", new[]
+ {
+ "ep07-135.mat=ep07.e3d,ep07 // 1.0,EP07-135,PKP Intercity,Warszawa,2019-04-01,Wrocław,Autor,Fotograf"
+ });
+
+ var meta = Assert.Single(entry!.Textures).Meta;
+ Assert.NotNull(meta);
+ Assert.Equal("EP07-135", meta!.Vehicle);
+ Assert.Equal("PKP Intercity", meta.Operator);
+ Assert.Equal("Warszawa", meta.Depot);
+ Assert.Equal("Autor", meta.TextureAuthor);
+ Assert.Equal("Fotograf", meta.PhotoAuthor);
+ }
+
+ [Fact]
+ public void Wreck_liveries_are_marked_so_the_depot_can_hide_them()
+ {
+ var entry = Parser.Parse("pkp/ep07/", new[] { "ep07wreck.mat=ep07.e3d,ep07" });
+
+ Assert.True(Assert.Single(entry!.Textures).Wreck);
+ }
+
+ [Fact]
+ public void The_dollar_a_marker_archives_everything_after_it()
+ {
+ var entry = Parser.Parse("pkp/ep07/", new[]
+ {
+ "ep07-135.mat=ep07.e3d,ep07",
+ "$a",
+ "ep07-old.mat=ep07.e3d,ep07old"
+ });
+
+ var groups = entry!.Groups;
+ Assert.Contains(groups, group => group is { Mini: "ep07", Archived: false });
+ Assert.Contains(groups, group => group is { Mini: "ep07old", Archived: true });
+ }
+
+ [Fact]
+ public void A_caret_opens_a_fixed_set_of_the_following_vehicles()
+ {
+ var entry = Parser.Parse("pkp/en57/", new[]
+ {
+ "^3",
+ "en57-a.mat=en57.e3d,en57",
+ "en57-b.mat=en57.e3d,en57",
+ "en57-c.mat=en57.e3d,en57"
+ });
+
+ var set = Assert.Single(entry!.Sets);
+ Assert.Equal(3, set.Count);
+ Assert.Equal(3, set.TextureRefs.Count);
+ }
+
+ [Fact]
+ public void Extra_equals_segments_become_alternative_models()
+ {
+ var entry = Parser.Parse("pkp/111a/", new[] { "111a.mat=111a.e3d,111a=111ab.e3d,111ab" });
+
+ var texture = Assert.Single(entry!.Textures);
+ Assert.Equal("111a.e3d", texture.Model);
+ Assert.Equal("111ab.e3d", Assert.Single(texture.Aliases).Model);
+ }
+
+ [Fact]
+ public void Comments_headers_and_blank_lines_are_ignored()
+ {
+ var entry = Parser.Parse("pkp/ep07/", new[] { "", "# nagłówek", "@ coś", "// komentarz", "bez znaku równości" });
+
+ Assert.Null(entry);
+ }
+
+ [Fact]
+ public void The_category_marker_applies_to_the_liveries_after_it()
+ {
+ var entry = Parser.Parse("pkp/ep07/", new[] { "!=e", "ep07-135.mat=ep07.e3d,ep07" });
+
+ Assert.Equal("e", Assert.Single(entry!.Groups).Category);
+ }
+}
diff --git a/StarterNG.Tests/TrainsetReadyTests.cs b/StarterNG.Tests/TrainsetReadyTests.cs
new file mode 100644
index 0000000..f0fc00d
--- /dev/null
+++ b/StarterNG.Tests/TrainsetReadyTests.cs
@@ -0,0 +1,56 @@
+using StarterNG.Classes;
+
+namespace StarterNG.Tests;
+
+public class TrainsetReadyTests
+{
+ private static Trainset Parse(string velocity) =>
+ new($"trainset pociag tor_1 10 {velocity}\n" +
+ "node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0\n" +
+ "endtrainset");
+
+ [Fact]
+ public void A_consist_authored_standing_still_is_not_ready()
+ {
+ Assert.False(Parse("0").ReadyToGo);
+ }
+
+ [Fact]
+ public void Marking_a_standing_consist_ready_gives_it_the_prepared_creep()
+ {
+ var trainset = Parse("0");
+
+ trainset.ReadyToGo = true;
+
+ Assert.True(trainset.ReadyToGo);
+ Assert.Equal(0.1f, trainset.Velocity, 3);
+ }
+
+ [Fact]
+ public void A_consist_authored_on_the_move_is_ready_and_keeps_its_speed()
+ {
+ var trainset = Parse("40");
+
+ Assert.True(trainset.ReadyToGo);
+
+ trainset.ReadyToGo = false;
+ Assert.Equal(0f, trainset.Velocity);
+
+ trainset.ReadyToGo = true;
+ Assert.Equal(40f, trainset.Velocity);
+ }
+
+ [Fact]
+ public void A_consist_running_the_other_way_keeps_its_direction()
+ {
+ // The sign of the speed is what DynObj.cpp turns into the running
+ // direction, so restoring readiness must not flip it.
+ var trainset = Parse("-40");
+
+ trainset.ReadyToGo = false;
+ trainset.ReadyToGo = true;
+
+ Assert.Equal(-40f, trainset.Velocity);
+ Assert.True(trainset.ReadyToGo);
+ }
+}
diff --git a/StarterNG.Tests/VehicleCatalogTests.cs b/StarterNG.Tests/VehicleCatalogTests.cs
new file mode 100644
index 0000000..63901e2
--- /dev/null
+++ b/StarterNG.Tests/VehicleCatalogTests.cs
@@ -0,0 +1,120 @@
+using StarterNG.Domain.Vehicles;
+using StarterNG.Tests.Fakes;
+
+namespace StarterNG.Tests;
+
+public class VehicleCatalogTests
+{
+ private static string TexturesFile(string maker, string vehicle) =>
+ TestInstallation.At("dynamic", maker, vehicle, "textures.txt");
+
+ [Fact]
+ public void The_catalogue_is_filled_from_every_vehicle_folder()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07")
+ .WithFile(TexturesFile("pkp", "111a"), "111a-1.mat=111a.e3d,111a");
+ var installation = new TestInstallation(files);
+
+ int liveries = installation.Vehicles.Load(installation.Library.Vehicles);
+
+ Assert.Equal(2, liveries);
+ Assert.Equal(2, installation.Library.Vehicles.Textures.Count);
+ }
+
+ [Fact]
+ public void The_data_folder_is_recorded_the_way_the_scenery_format_spells_it()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07");
+ var installation = new TestInstallation(files);
+ installation.Vehicles.Load(installation.Library.Vehicles);
+
+ Assert.Equal("pkp/ep07/", Assert.Single(installation.Library.Vehicles.Textures).Directory);
+ }
+
+ [Fact]
+ public void Wrecks_are_indexed_but_kept_out_of_the_offered_liveries()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TexturesFile("pkp", "ep07"), "ep07wrak.mat=ep07.e3d,ep07\nep07-135.mat=ep07.e3d,ep07");
+ var installation = new TestInstallation(files);
+ installation.Vehicles.Load(installation.Library.Vehicles);
+
+ var catalog = installation.Library.Vehicles;
+ Assert.Equal("ep07-135", Assert.Single(catalog.Textures).Skinfile);
+ Assert.NotNull(catalog.TextureForSkin("ep07wrak"));
+ }
+
+ [Fact]
+ public void A_livery_is_found_by_its_skin_file_whatever_the_extension()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TexturesFile("pkp", "ep07"), "ep07-135.mat=ep07.e3d,ep07");
+ var installation = new TestInstallation(files);
+ installation.Vehicles.Load(installation.Library.Vehicles);
+
+ Assert.NotNull(installation.Library.Vehicles.TextureForSkin("EP07-135.bmp"));
+ Assert.Null(installation.Library.Vehicles.TextureForSkin("nieznany"));
+ }
+
+ [Fact]
+ public void A_star_category_is_derived_from_the_first_letter_of_the_thumbnail()
+ {
+ var catalog = new VehicleCatalog(new MiniStub());
+ catalog.BeginLoad();
+ catalog.Ingest(ParsedEntry("pkp/ep07/", "ep07-135.mat=ep07.e3d,ep07"));
+ catalog.EndLoad();
+
+ Assert.Equal("E", Assert.Single(catalog.Textures).ResolvedCategory);
+ }
+
+ [Fact]
+ public void Members_of_a_set_after_the_first_are_followers()
+ {
+ var files = new InMemoryFileSystem()
+ .WithFile(TexturesFile("pkp", "en57"),
+ "^2\nen57-a.mat=en57.e3d,en57\nen57-b.mat=en57.e3d,en57");
+ var installation = new TestInstallation(files);
+ installation.Vehicles.Load(installation.Library.Vehicles);
+ var catalog = installation.Library.Vehicles;
+
+ var lead = catalog.TextureForSkin("en57-a")!;
+ var follower = catalog.TextureForSkin("en57-b")!;
+
+ Assert.False(catalog.IsSetFollower(lead));
+ Assert.True(catalog.IsSetFollower(follower));
+ Assert.Equal(2, catalog.ResolveSet(lead)!.Count);
+ }
+
+ [Fact]
+ public void A_livery_falls_back_to_its_groups_thumbnail_when_its_own_is_missing()
+ {
+ var catalog = new VehicleCatalog(new MiniStub(known: "ep07"));
+ catalog.BeginLoad();
+ catalog.Ingest(ParsedEntry("pkp/ep07/", "ep07-135.mat=ep07.e3d,ep07,brakujacy"));
+ catalog.EndLoad();
+
+ Assert.Equal("ep07", catalog.ResolveMiniName(Assert.Single(catalog.Textures)));
+ }
+
+ /// Thumbnail index that knows about exactly one name.
+ private sealed class MiniStub : StarterNG.Application.Abstractions.IMiniTextureIndex
+ {
+ private readonly string? _known;
+
+ public MiniStub(string? known = null) => _known = known;
+
+ public void Preload() { }
+
+ public bool Has(string? miniName) => miniName is not null && miniName == _known;
+
+ public string? PathFor(string? miniName) => Has(miniName) ? miniName : FallbackPath;
+
+ public string? FallbackPath => null;
+ }
+
+ /// Builds an entry through the real parser, for catalogue-level tests.
+ private static VehicleEntry ParsedEntry(string directory, params string[] lines) =>
+ new StarterNG.Infrastructure.Vehicles.TexturesTxtParser().Parse(directory, lines)!;
+}
diff --git a/StarterNG.sln b/StarterNG.sln
index 7afd782..262289f 100644
--- a/StarterNG.sln
+++ b/StarterNG.sln
@@ -5,6 +5,8 @@ VisualStudioVersion = 18.1.11312.151 d18.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG", "StarterNG\StarterNG.csproj", "{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StarterNG.Tests", "StarterNG.Tests\StarterNG.Tests.csproj", "{00B068D7-2F49-4CAA-BC21-506503EBDFC8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -21,6 +23,14 @@ Global
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|Any CPU.Build.0 = Release|Any CPU
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.ActiveCfg = Release|x64
{D1E8A3A3-3269-415A-94AD-D6F370D7E4FD}.Release|x64.Build.0 = Release|x64
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Debug|x64.Build.0 = Debug|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.ActiveCfg = Release|Any CPU
+ {00B068D7-2F49-4CAA-BC21-506503EBDFC8}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/StarterNG/App.axaml b/StarterNG/App.axaml
index a347f53..99f30bc 100644
--- a/StarterNG/App.axaml
+++ b/StarterNG/App.axaml
@@ -1,22 +1,24 @@
-
+
+
+
-
#177f00
#136B00
#0F5600
@@ -29,5 +31,13 @@
+
+ 6
+ 6
+ 24
+ 12
+ 12
+ 2
+ 4
diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs
index fa255b6..52a2cad 100644
--- a/StarterNG/App.axaml.cs
+++ b/StarterNG/App.axaml.cs
@@ -8,95 +8,185 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
+using StarterNG.Application;
using StarterNG.Classes;
+using StarterNG.Infrastructure;
using StarterNG.Services;
namespace StarterNG;
-public partial class App : Application
+public partial class App : Avalonia.Application
{
-
- public static LocalizationService Loc { get; } = new();
-
+
+ ///
+ /// The translation table, as a static source for the XAML bindings. Code
+ /// should take
+ /// through its constructor rather than reading this.
+ ///
+ public static LocalizationService Loc => AppServices.Current.Localization;
+
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
- // Load the persisted configuration up front so the saved language wins;
- // fall back to the system culture only when the file has no lang entry.
- Settings.Instance.Load();
+ AppServices.Current.SettingsStore.Load();
- // Key bindings live in eu07_input-keyboard.ini next to eu07.ini.
KeyboardConfig.Instance.Load();
CultureInfo ci = CultureInfo.InstalledUICulture ;
- var langName = Settings.Instance.LanguageWasSet
- ? Settings.Instance.Language
+ var langName = AppServices.Current.Settings.LanguageWasSet
+ ? AppServices.Current.Settings.Language
: ci.Name switch
{
"pl-PL" => "Polski",
_ => "English"
};
- Settings.Instance.Language = langName;
+ AppServices.Current.Settings.Language = langName;
+
+ // What eu07.ini asked for, before the starter picks a language it can
+ // actually display.
+ string requestedCode = AppServices.Current.Settings.LanguageCode;
ApplyLanguage(langName);
+
+ // The starter ships no translation for this code, so its own interface
+ // falls back - but the simulator has one and keeps it. Overwriting the key
+ // here would silently move eu07.exe to English on the next save.
+ if (!string.IsNullOrWhiteSpace(requestedCode) &&
+ !string.Equals(requestedCode, Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase))
+ AppServices.Current.Settings.LanguageCode = requestedCode;
}
- ///
- /// Swaps the active language by loading the matching XML file from the
- /// lang/ folder next to the executable (see ).
- /// The translations are no longer compiled into the assembly, so they can be
- /// edited or added without rebuilding the launcher. Accepts either a display
- /// name ("English", "Polski") or a short code ("en", "pl").
- ///
public static void ApplyLanguage(string langNameOrCode)
{
- // Loc.Load resolves the file and returns the canonical display name,
- // which we mirror back into the settings so the stored value stays valid.
+
var resolvedName = Loc.Load(langNameOrCode);
- Settings.Instance.Language = resolvedName;
+
+ // The service is the authority on which language actually loaded: the code
+ // goes to eu07.ini for the simulator, the name to the picker.
+ AppServices.Current.Settings.Language = resolvedName;
+ AppServices.Current.Settings.LanguageCode = Loc.CurrentLangCode;
+
+ ApplyCulture(Loc.CurrentLangCode);
+ }
+
+ private static void ApplyCulture(string langCode)
+ {
+ CultureInfo culture;
+ try
+ {
+ culture = langCode switch
+ {
+ "" or "en" => CultureInfo.InvariantCulture,
+ "cz" => CultureInfo.GetCultureInfo("cs"),
+ _ => CultureInfo.GetCultureInfo(langCode)
+ };
+ }
+ catch (CultureNotFoundException)
+ {
+ culture = CultureInfo.InvariantCulture;
+ }
+
+ CultureInfo.DefaultThreadCurrentCulture = culture;
+ CultureInfo.DefaultThreadCurrentUICulture = culture;
+ CultureInfo.CurrentCulture = culture;
+ CultureInfo.CurrentUICulture = culture;
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
- // Code page 1250 is needed to parse scenery files (done during load).
+
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
- // Persist settings when the launcher is closing. Key bindings are only
- // rewritten when actually edited, so an untouched file keeps its layout.
desktop.ShutdownRequested += (_, _) =>
{
- Settings.Instance.CaptureAndSave();
- if (KeyboardConfig.Instance.Dirty)
- KeyboardConfig.Instance.Save();
+ try { AppServices.Current.SettingsStore.CaptureAndSave(); }
+ catch (Exception ex) { Diagnostics.Log("Settings save", ex); }
+
+ AppServices.Current.MissingVehicleLog.Dump();
+
+ try
+ {
+ if (KeyboardConfig.Instance.Dirty)
+ KeyboardConfig.Instance.Save();
+ }
+ catch (Exception ex) { Diagnostics.Log("Keyboard config save", ex); }
+
+ Diagnostics.Flush();
};
- var splash = new SplashWindow();
- desktop.MainWindow = splash;
- splash.Show();
-
- // Progress marshals callbacks back to this (UI) thread.
- var progress = new Progress(splash.Report);
+ SplashWindow? splash = null;
+ bool mainReady = false;
+ var sw = System.Diagnostics.Stopwatch.StartNew();
Task.Run(async () =>
{
- GameData.Instance.Load(progress);
- await Task.Delay(400); // let the completed bar be visible briefly
- }).ContinueWith(_ =>
- {
- Dispatcher.UIThread.Post(() =>
+ var progress = new Progress(status =>
{
- var main = new MainWindow();
- desktop.MainWindow = main; // keep a main window before closing the splash
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (mainReady)
+ return;
+
+ if (sw.ElapsedMilliseconds > 300 && splash == null)
+ {
+ splash = new SplashWindow();
+ desktop.MainWindow = splash;
+ splash.Show();
+ }
+ splash?.Report(status);
+ });
+ });
+
+ AppServices.Current.Library.Load(progress);
+ }).ContinueWith(load =>
+ {
+ Dispatcher.UIThread.Post(async () =>
+ {
+ MainWindow main;
+ try
+ {
+ main = new MainWindow();
+ }
+ catch (Exception ex)
+ {
+ Diagnostics.Log("Main window", ex);
+ Diagnostics.Flush();
+ Console.Error.WriteLine(ex);
+ mainReady = true;
+ splash?.Close();
+ desktop.Shutdown(1);
+ return;
+ }
+
+ mainReady = true;
+ desktop.MainWindow = main;
main.Show();
- splash.Close();
+ splash?.Close();
+ splash = null;
+
+ if (load.IsFaulted && load.Exception is { } error)
+ {
+ var inner = error.GetBaseException();
+ Diagnostics.Log("Game data load", inner);
+ await Diagnostics.ReportAsync(
+ $"{Loc["FaultLoadData"]}{Environment.NewLine}{Environment.NewLine}{Loc["FaultDetail"]} {inner.Message}");
+ }
+
+ var faults = Diagnostics.CheckInstallation();
+ if (faults.Count > 0)
+ {
+ await Diagnostics.ReportAsync(
+ string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine +
+ Loc["FaultBadInstall"]);
+ }
});
});
}
base.OnFrameworkInitializationCompleted();
}
-}
\ No newline at end of file
+}
diff --git a/StarterNG/AppInfo.cs b/StarterNG/AppInfo.cs
index 1fe417b..ddeb6f1 100644
--- a/StarterNG/AppInfo.cs
+++ b/StarterNG/AppInfo.cs
@@ -2,14 +2,9 @@ using System.Reflection;
namespace StarterNG;
-/// Static, display-ready information about the running launcher build.
public static class AppInfo
{
- ///
- /// The launcher's assembly version (e.g. "1.2.3.4"), with a " (DEBUG)" suffix
- /// appended for Debug builds so debug and release binaries are easy to tell
- /// apart. Computed once; safe to bind with {x:Static}.
- ///
+
public static string Version { get; } = BuildVersion();
private static string BuildVersion()
diff --git a/StarterNG/Application/Abstractions/IClock.cs b/StarterNG/Application/Abstractions/IClock.cs
new file mode 100644
index 0000000..97f0c2c
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IClock.cs
@@ -0,0 +1,10 @@
+namespace StarterNG.Application.Abstractions;
+
+using System;
+
+public interface IClock
+{
+ DateTime Now { get; }
+
+ DateTime UtcNow { get; }
+}
diff --git a/StarterNG/Application/Abstractions/IDiagnosticsLog.cs b/StarterNG/Application/Abstractions/IDiagnosticsLog.cs
new file mode 100644
index 0000000..a5a0765
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IDiagnosticsLog.cs
@@ -0,0 +1,19 @@
+using System;
+using System.Collections.Generic;
+
+namespace StarterNG.Application.Abstractions;
+
+public interface IDiagnosticsLog
+{
+ void Log(string message);
+
+ void Log(string context, Exception exception);
+
+ void Log(IEnumerable messages);
+
+ string Text { get; }
+
+ void Clear();
+
+ void Flush();
+}
diff --git a/StarterNG/Application/Abstractions/IEnvironment.cs b/StarterNG/Application/Abstractions/IEnvironment.cs
new file mode 100644
index 0000000..bc614ab
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IEnvironment.cs
@@ -0,0 +1,14 @@
+namespace StarterNG.Application.Abstractions;
+
+public interface IEnvironment
+{
+ bool IsWindows { get; }
+
+ bool IsLinux { get; }
+
+ bool IsMacOS { get; }
+
+ string? GetVariable(string name);
+
+ string BaseDirectory { get; }
+}
diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs
new file mode 100644
index 0000000..93c1083
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IFileSystem.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+
+namespace StarterNG.Application.Abstractions;
+
+public interface IFileSystem
+{
+ bool FileExists(string path);
+
+ bool DirectoryExists(string path);
+
+ string ReadAllText(string path);
+
+ string ReadAllText(string path, Encoding encoding);
+
+ string[] ReadAllLines(string path);
+
+ Stream OpenRead(string path);
+
+ void WriteAllText(string path, string contents);
+
+ void WriteAllText(string path, string contents, Encoding encoding);
+
+ void WriteAllLines(string path, IEnumerable lines);
+
+ void WriteAllBytes(string path, byte[] bytes);
+
+ void CreateDirectory(string path);
+
+ void DeleteFile(string path);
+
+ void CopyFile(string sourcePath, string destinationPath, bool overwrite);
+
+ IReadOnlyList GetFiles(string path, string searchPattern = "*");
+
+ IReadOnlyList GetDirectories(string path);
+
+ IReadOnlyList GetFilesRecursive(string path, string searchPattern);
+
+ DateTime GetLastWriteTimeUtc(string path);
+
+ bool IsExecutable(string path);
+}
diff --git a/StarterNG/Application/Abstractions/IGamePaths.cs b/StarterNG/Application/Abstractions/IGamePaths.cs
new file mode 100644
index 0000000..314647c
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IGamePaths.cs
@@ -0,0 +1,20 @@
+namespace StarterNG.Application.Abstractions;
+
+public interface IGamePaths
+{
+ string Root { get; }
+
+ string Scenery { get; }
+
+ string Dynamic { get; }
+
+ string MiniTextures { get; }
+
+ string Data { get; }
+
+ string StarterConfig { get; }
+
+ string DiagnosticsLog { get; }
+
+ string FromRoot(params string[] segments);
+}
diff --git a/StarterNG/Application/Abstractions/ILocalizedStrings.cs b/StarterNG/Application/Abstractions/ILocalizedStrings.cs
new file mode 100644
index 0000000..826d732
--- /dev/null
+++ b/StarterNG/Application/Abstractions/ILocalizedStrings.cs
@@ -0,0 +1,12 @@
+namespace StarterNG.Application.Abstractions;
+
+public interface ILocalizedStrings
+{
+ string this[string key] { get; }
+
+ string Opt(string key);
+
+ string CurrentLanguage { get; }
+
+ string CurrentLangCode { get; }
+}
diff --git a/StarterNG/Application/Abstractions/IMiniTextureIndex.cs b/StarterNG/Application/Abstractions/IMiniTextureIndex.cs
new file mode 100644
index 0000000..1edadd4
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IMiniTextureIndex.cs
@@ -0,0 +1,12 @@
+namespace StarterNG.Application.Abstractions;
+
+public interface IMiniTextureIndex
+{
+ void Preload();
+
+ bool Has(string? miniName);
+
+ string? PathFor(string? miniName);
+
+ string? FallbackPath { get; }
+}
diff --git a/StarterNG/Application/Abstractions/IPhysicsRepository.cs b/StarterNG/Application/Abstractions/IPhysicsRepository.cs
new file mode 100644
index 0000000..d479167
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IPhysicsRepository.cs
@@ -0,0 +1,12 @@
+using StarterNG.Domain.Vehicles;
+
+namespace StarterNG.Application.Abstractions;
+
+public interface IPhysicsRepository
+{
+ void Preload();
+
+ int IndexedCount { get; }
+
+ VehiclePhysics? For(string? dataFolder, string? model);
+}
diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs
new file mode 100644
index 0000000..a8ecab1
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs
@@ -0,0 +1,22 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace StarterNG.Application.Abstractions;
+
+public interface IProcessHandle
+{
+ bool HasExited { get; }
+
+ Task WaitForExitAsync(CancellationToken cancellationToken = default);
+}
+
+public interface IProcessLauncher
+{
+ IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory,
+ out string? error);
+
+ bool OpenInShell(string pathOrUrl);
+
+ IProcessHandle? FindRunning(string processName);
+}
diff --git a/StarterNG/Application/Abstractions/IRandomSource.cs b/StarterNG/Application/Abstractions/IRandomSource.cs
new file mode 100644
index 0000000..b4403a2
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IRandomSource.cs
@@ -0,0 +1,8 @@
+namespace StarterNG.Application.Abstractions;
+
+public interface IRandomSource
+{
+ int Next(int minInclusive, int maxExclusive);
+
+ double NextDouble();
+}
diff --git a/StarterNG/Application/Abstractions/ISceneryRepository.cs b/StarterNG/Application/Abstractions/ISceneryRepository.cs
new file mode 100644
index 0000000..031b05b
--- /dev/null
+++ b/StarterNG/Application/Abstractions/ISceneryRepository.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using StarterNG.Domain.Sceneries;
+
+namespace StarterNG.Application.Abstractions;
+
+public readonly record struct SceneryLoadProgress(int Loaded, int Total, string FileName);
+
+public interface ISceneryRepository
+{
+ IReadOnlyList LoadAll(IProgress? progress = null);
+
+ Scenery? Load(string path);
+}
diff --git a/StarterNG/Application/Abstractions/ISceneryTranslations.cs b/StarterNG/Application/Abstractions/ISceneryTranslations.cs
new file mode 100644
index 0000000..de599ce
--- /dev/null
+++ b/StarterNG/Application/Abstractions/ISceneryTranslations.cs
@@ -0,0 +1,10 @@
+using StarterNG.Domain.Sceneries;
+
+namespace StarterNG.Application.Abstractions;
+
+public interface ISceneryTranslations
+{
+ void LoadFor(Scenery scenery, string langCode);
+
+ string Translate(string? text);
+}
diff --git a/StarterNG/Application/Abstractions/IVehicleRepository.cs b/StarterNG/Application/Abstractions/IVehicleRepository.cs
new file mode 100644
index 0000000..5e285f2
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IVehicleRepository.cs
@@ -0,0 +1,8 @@
+using StarterNG.Domain.Vehicles;
+
+namespace StarterNG.Application.Abstractions;
+
+public interface IVehicleRepository
+{
+ int Load(VehicleCatalog catalog);
+}
diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs
new file mode 100644
index 0000000..e223746
--- /dev/null
+++ b/StarterNG/Application/AppServices.cs
@@ -0,0 +1,115 @@
+using System;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Settings;
+using StarterNG.Infrastructure.Adapters;
+using StarterNG.Infrastructure.Sceneries;
+using StarterNG.Infrastructure.Settings;
+using StarterNG.Infrastructure.Vehicles;
+using StarterNG.Services;
+
+namespace StarterNG.Application;
+
+// Skladany recznie: PublishAot i PublishTrimmed wykluczaja kontener oparty na
+// refleksji. Current jest service locatorem i wolno go czytac tylko z entry
+// pointu i z konstruktorow widokow, ktorych Avalonia nie potrafi parametryzowac.
+public sealed class AppServices
+{
+ private static AppServices? _current;
+
+ private AppServices(IGamePaths paths, IFileSystem files, IClock clock, IEnvironment environment)
+ {
+ Paths = paths;
+ Files = files;
+ Clock = clock;
+ Environment = environment;
+
+ 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);
+ MiniTextures = new MiniTextureIndex(files, paths);
+ Vehicles = new TexturesTxtVehicleRepository(files, paths, log, new TexturesTxtParser());
+ Physics = new FizPhysicsRepository(files, paths, log);
+ MissingAssets = new MissingAssetScanner(files, paths);
+ SceneryTexts = new SceneryTranslations(files, log);
+ Timetables = new TimetableLocator(files);
+ LoadWeights = new LoadWeightsRepository(files, paths, log);
+ Library = new GameLibrary(Vehicles, Sceneries, MiniTextures, Physics, log);
+ State = new AppState();
+
+ var settingsPaths = new SettingsPaths(environment, paths);
+ Executables = new ExecutableLocator(files, paths, environment, log);
+ SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables);
+ SettingsPaths = settingsPaths;
+ MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log);
+ StartSimulation = new StartSimulation(State, SettingsStore, files, Processes, Random, log);
+ }
+
+ public static AppServices Current =>
+ _current ?? throw new InvalidOperationException(
+ "AppServices.Current read before Initialize(); the composition root must be built first.");
+
+ public static AppServices Initialize(IGamePaths? paths = null, IFileSystem? files = null, IClock? clock = null,
+ IEnvironment? environment = null)
+ {
+ _current = new AppServices(
+ paths ?? GamePaths.ForCurrentDirectory(),
+ files ?? new PhysicalFileSystem(),
+ clock ?? new SystemClock(),
+ environment ?? new SystemEnvironment());
+ return _current;
+ }
+
+ public IGamePaths Paths { get; }
+
+ public IFileSystem Files { get; }
+
+ public IClock Clock { get; }
+
+ public IEnvironment Environment { get; }
+
+ public IDiagnosticsLog Log { get; }
+
+ public IProcessLauncher Processes { get; }
+
+ public IRandomSource Random { get; }
+
+ public ISceneryRepository Sceneries { get; }
+
+ public SceneryImageLocator SceneryImages { get; }
+
+ public IMiniTextureIndex MiniTextures { get; }
+
+ public IVehicleRepository Vehicles { get; }
+
+ public IPhysicsRepository Physics { get; }
+
+ public MissingAssetScanner MissingAssets { get; }
+
+ public ISceneryTranslations SceneryTexts { get; }
+
+ public TimetableLocator Timetables { get; }
+
+ public LoadWeightsRepository LoadWeights { get; }
+
+ public GameLibrary Library { get; }
+
+ public AppState State { get; }
+
+ public SettingsStore SettingsStore { get; }
+
+ public SimulatorSettings Settings => SettingsStore.Settings;
+
+ public ExecutableLocator Executables { get; }
+
+ public SettingsPaths SettingsPaths { get; }
+
+ public MissingVehicleLog MissingVehicleLog { get; }
+
+ public StartSimulation StartSimulation { get; }
+
+ public LocalizationService Localization { get; }
+}
diff --git a/StarterNG/Application/AppState.cs b/StarterNG/Application/AppState.cs
new file mode 100644
index 0000000..78ab716
--- /dev/null
+++ b/StarterNG/Application/AppState.cs
@@ -0,0 +1,50 @@
+using System;
+using StarterNG.Domain.Sceneries;
+
+using StarterNG.Classes;
+
+namespace StarterNG.Application;
+
+public sealed class AppState
+{
+ public event Action? Changed;
+
+ private Scenery? _currentScenery;
+ private Trainset? _currentTrainset;
+ private string? _startingVehicleName;
+
+ public Scenery? CurrentScenery
+ {
+ get => _currentScenery;
+ set
+ {
+ if (ReferenceEquals(_currentScenery, value)) return;
+ _currentScenery = value;
+ Changed?.Invoke();
+ }
+ }
+
+ public Trainset? CurrentTrainset
+ {
+ get => _currentTrainset;
+ set
+ {
+ if (ReferenceEquals(_currentTrainset, value)) return;
+ _currentTrainset = value;
+ Changed?.Invoke();
+ }
+ }
+
+ public string? StartingVehicleName
+ {
+ get => _startingVehicleName;
+ set
+ {
+ if (_startingVehicleName == value) return;
+ _startingVehicleName = value;
+ Changed?.Invoke();
+ }
+ }
+
+ public void NotifyChanged() => Changed?.Invoke();
+}
diff --git a/StarterNG/Application/GameLibrary.cs b/StarterNG/Application/GameLibrary.cs
new file mode 100644
index 0000000..1f94b67
--- /dev/null
+++ b/StarterNG/Application/GameLibrary.cs
@@ -0,0 +1,70 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Sceneries;
+using StarterNG.Domain.Vehicles;
+
+namespace StarterNG.Application;
+
+public enum LoadPhase
+{
+ Vehicles,
+ Sceneries,
+ Done
+}
+
+public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
+
+public sealed class GameLibrary
+{
+ private readonly IVehicleRepository _vehicles;
+ private readonly ISceneryRepository _sceneries;
+ private readonly IMiniTextureIndex _minis;
+ private readonly IPhysicsRepository _physics;
+ private readonly IDiagnosticsLog _log;
+
+ public GameLibrary(IVehicleRepository vehicles, ISceneryRepository sceneries, IMiniTextureIndex minis,
+ IPhysicsRepository physics, IDiagnosticsLog log)
+ {
+ _vehicles = vehicles;
+ _sceneries = sceneries;
+ _minis = minis;
+ _physics = physics;
+ _log = log;
+ Vehicles = new VehicleCatalog(minis);
+ }
+
+ public VehicleCatalog Vehicles { get; }
+
+ public List Sceneries { get; } = new();
+
+ public bool Loaded { get; private set; }
+
+ public void Load(IProgress? progress = null)
+ {
+ if (Loaded)
+ return;
+
+ progress?.Report(new LoadStatus(0, LoadPhase.Vehicles, "textures.txt"));
+ if (_vehicles.Load(Vehicles) == 0)
+ _log.Log("dynamic: brak plikow textures.txt");
+
+ Parallel.Invoke(_minis.Preload, _physics.Preload);
+
+ ReloadSceneries(progress is null
+ ? null
+ : new Progress(step => progress.Report(
+ new LoadStatus((double)step.Loaded / step.Total, LoadPhase.Sceneries, step.FileName))));
+
+ progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
+ Loaded = true;
+ }
+
+ public void ReloadSceneries(IProgress? progress = null)
+ {
+ var reloaded = _sceneries.LoadAll(progress);
+ Sceneries.Clear();
+ Sceneries.AddRange(reloaded);
+ }
+}
diff --git a/StarterNG/Application/MissingVehicleLog.cs b/StarterNG/Application/MissingVehicleLog.cs
new file mode 100644
index 0000000..5d3ff7e
--- /dev/null
+++ b/StarterNG/Application/MissingVehicleLog.cs
@@ -0,0 +1,40 @@
+using System;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Settings;
+using StarterNG.Infrastructure.Vehicles;
+
+namespace StarterNG.Application;
+
+public sealed class MissingVehicleLog
+{
+ private readonly SimulatorSettings _settings;
+ private readonly MissingAssetScanner _scanner;
+ private readonly GameLibrary _library;
+ private readonly IDiagnosticsLog _log;
+
+ public MissingVehicleLog(SimulatorSettings settings, MissingAssetScanner scanner, GameLibrary library,
+ IDiagnosticsLog log)
+ {
+ _settings = settings;
+ _scanner = scanner;
+ _library = library;
+ _log = log;
+ }
+
+ public void Dump()
+ {
+ if (!_settings.LogMissingVehicleFiles)
+ return;
+
+ try
+ {
+ var lines = _scanner.Scan(_library.Vehicles);
+ if (lines.Count > 0)
+ _log.Log(lines);
+ }
+ catch (Exception ex)
+ {
+ _log.Log("Missing vehicle log", ex);
+ }
+ }
+}
diff --git a/StarterNG/Application/SettingsStore.cs b/StarterNG/Application/SettingsStore.cs
new file mode 100644
index 0000000..bb56352
--- /dev/null
+++ b/StarterNG/Application/SettingsStore.cs
@@ -0,0 +1,143 @@
+using System;
+using System.IO;
+using System.Text;
+using StarterNG.Application.Abstractions;
+using StarterNG.Classes;
+using StarterNG.Domain.Settings;
+using StarterNG.Infrastructure.Settings;
+using StarterNG.Infrastructure.Adapters;
+
+namespace StarterNG.Application;
+
+public interface ISettingsCapture
+{
+ void CaptureInto(SimulatorSettings settings);
+}
+
+public sealed class SettingsStore
+{
+ private readonly IFileSystem _files;
+ private readonly IClock _clock;
+ private readonly IDiagnosticsLog _log;
+ private readonly SettingsPaths _paths;
+ private readonly SettingsSerializer _serializer;
+ private readonly ExecutableLocator _executables;
+ private readonly Encoding _encoding;
+
+ private ConfigFile _config = new();
+ private string _savePath = string.Empty;
+ private DateTime _configAgeUtc = DateTime.MinValue;
+ private ISettingsCapture? _capture;
+
+ public SettingsStore(IFileSystem files, IClock clock, IDiagnosticsLog log, SettingsPaths paths,
+ SettingsSerializer serializer, ExecutableLocator executables)
+ {
+ _files = files;
+ _clock = clock;
+ _log = log;
+ _paths = paths;
+ _serializer = serializer;
+ _executables = executables;
+ _encoding = LegacyText.CodePage1250;
+ }
+
+ public SimulatorSettings Settings { get; } = new();
+
+ public string LoadedFrom { get; private set; } = string.Empty;
+
+ public string SavePath => _savePath;
+
+ public void RegisterCapture(ISettingsCapture capture) => _capture = capture;
+
+ public void Load()
+ {
+ _savePath = _paths.UserConfigPath();
+ string source = _files.FileExists(_savePath) ? _savePath : _paths.InstallationConfigPath();
+ LoadedFrom = source;
+ LoadFrom(source);
+ }
+
+ public void LoadFrom(string path)
+ {
+ try
+ {
+ _config = _files.FileExists(path)
+ ? ConfigFile.Parse(_files.ReadAllText(path, _encoding))
+ : new ConfigFile();
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"reading {path}", ex);
+ _config = new ConfigFile();
+ }
+
+ _serializer.Read(Settings, _config);
+ TouchConfigAge();
+ }
+
+ public void CaptureAndSave()
+ {
+ _capture?.CaptureInto(Settings);
+ Save();
+ }
+
+ public void Save()
+ {
+ if (string.IsNullOrEmpty(_savePath))
+ _savePath = _paths.UserConfigPath();
+
+ SaveTo(_savePath);
+ }
+
+ public void SaveTo(string path)
+ {
+ _serializer.Write(Settings, _config);
+
+ try
+ {
+ string? directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ _files.CreateDirectory(directory);
+
+ _files.WriteAllText(path, _config.ToText(), _encoding);
+ TouchConfigAge();
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"saving {path}", ex);
+ }
+ }
+
+ public void TouchConfigAge()
+ {
+ string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath;
+ try
+ {
+ _configAgeUtc = _files.FileExists(path) ? _files.GetLastWriteTimeUtc(path) : _clock.UtcNow;
+ }
+ catch (Exception)
+ {
+ _configAgeUtc = _clock.UtcNow;
+ }
+ }
+
+ public bool IsConfigNewerOnDisk()
+ {
+ string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath;
+ try
+ {
+ if (!_files.FileExists(path) || _configAgeUtc == DateTime.MinValue)
+ return false;
+
+ return _files.GetLastWriteTimeUtc(path) > _configAgeUtc + TimeSpan.FromSeconds(1);
+ }
+ catch (Exception)
+ {
+ return false;
+ }
+ }
+
+ public string ResolveExecutable() => _executables.Resolve(Settings, out _);
+
+ public string ResolveExecutable(out ExeProblem problem) => _executables.Resolve(Settings, out problem);
+}
diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs
new file mode 100644
index 0000000..8e05d29
--- /dev/null
+++ b/StarterNG/Application/StartSimulation.cs
@@ -0,0 +1,135 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Text;
+using StarterNG.Application.Abstractions;
+using StarterNG.Classes;
+using StarterNG.Domain;
+using StarterNG.Domain.Settings;
+using StarterNG.Infrastructure.Adapters;
+
+namespace StarterNG.Application;
+
+public enum SimulationStartOutcome
+{
+ Started,
+
+ NothingSelected,
+
+ ExportFailed,
+
+ ExecutableProblem,
+
+ LaunchFailed
+}
+
+public readonly record struct SimulationStartResult(
+ SimulationStartOutcome Outcome,
+ IProcessHandle? Process = null,
+ string ExecutablePath = "",
+ ExeProblem Problem = ExeProblem.None,
+ string? Detail = null);
+
+public sealed class StartSimulation
+{
+ private const char ExportPrefix = '$';
+
+
+ private readonly AppState _state;
+ private readonly SettingsStore _settings;
+ private readonly IFileSystem _files;
+ private readonly IProcessLauncher _processes;
+ private readonly IRandomSource _random;
+ private readonly IDiagnosticsLog _log;
+
+ public StartSimulation(AppState state, SettingsStore settings, IFileSystem files, IProcessLauncher processes,
+ IRandomSource random, IDiagnosticsLog log)
+ {
+ _state = state;
+ _settings = settings;
+ _files = files;
+ _processes = processes;
+ _random = random;
+ _log = log;
+ }
+
+ public static string? StartableVehicle(Trainset? trainset, string? preferred)
+ {
+ if (trainset is null || trainset.Vehicles.Count == 0)
+ return null;
+
+ static bool CanStart(Dynamic vehicle) =>
+ vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger;
+
+ if (!string.IsNullOrEmpty(preferred))
+ {
+ var picked = trainset.Vehicles.FirstOrDefault(vehicle =>
+ string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase));
+ if (picked is not null && CanStart(picked))
+ return picked.Name;
+ }
+
+ return trainset.Vehicles.FirstOrDefault(CanStart)?.Name;
+ }
+
+ public SimulationStartResult Execute(bool freeFly, bool saveSettings)
+ {
+ var scenery = _state.CurrentScenery;
+ var trainset = _state.CurrentTrainset;
+ if (scenery is null || trainset is null)
+ return new SimulationStartResult(SimulationStartOutcome.NothingSelected);
+
+ // The depot checkbox is the consist's own state; this setting is a blanket
+ // override for people who always want the same thing, and it wins.
+ switch (_settings.Settings.ReadyOverride)
+ {
+ case ConsistReadyOverride.AlwaysCold:
+ trainset.ReadyToGo = false;
+ break;
+ case ConsistReadyOverride.AlwaysReady:
+ trainset.ReadyToGo = true;
+ break;
+ }
+
+ string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName)
+ ?? StartableVehicle(trainset, _state.StartingVehicleName);
+ _state.StartingVehicleName = vehicle;
+
+ scenery.Weather.Dirty = true;
+
+ string exportName = ExportPrefix + Path.GetFileName(scenery.Path);
+ string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName);
+ try
+ {
+ _files.WriteAllText(exportPath,
+ scenery.BuildExportContent(_settings.Settings.IgnoreIrrelevantTrains, _random),
+ LegacyText.CodePage1250);
+ }
+ catch (Exception ex)
+ {
+ _log.Log($"writing {exportPath}", ex);
+ return new SimulationStartResult(SimulationStartOutcome.ExportFailed, Detail: ex.Message,
+ ExecutablePath: exportPath);
+ }
+
+ if (saveSettings)
+ _settings.CaptureAndSave();
+
+ string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem));
+ if (problem != ExeProblem.None)
+ return new SimulationStartResult(SimulationStartOutcome.ExecutableProblem, ExecutablePath: executable,
+ Problem: problem);
+
+ string[] arguments = freeFly || string.IsNullOrEmpty(vehicle)
+ ? new[] { "-s", exportName }
+ : new[] { "-s", exportName, "-v", vehicle };
+
+ var process = _processes.Start(executable, arguments, Path.GetDirectoryName(executable), out string? error);
+ if (process is null)
+ return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable,
+ Detail: error);
+
+ return new SimulationStartResult(SimulationStartOutcome.Started, process, executable);
+ }
+
+}
diff --git a/StarterNG/Assets/starter.ico b/StarterNG/Assets/starter.ico
new file mode 100644
index 0000000..2008b76
Binary files /dev/null and b/StarterNG/Assets/starter.ico differ
diff --git a/StarterNG/Classes/AppState.cs b/StarterNG/Classes/AppState.cs
deleted file mode 100644
index fbbdf0a..0000000
--- a/StarterNG/Classes/AppState.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-namespace StarterNG.Classes;
-
-///
-/// Shared selection state across views: the scenery and trainset currently in
-/// focus. The depot edits this trainset in place, so changes made in the depot
-/// are reflected on the scenery (both views share the same Scenery/Trainset
-/// objects from ).
-///
-public sealed class AppState
-{
- public static AppState Instance { get; } = new();
-
- public Scenery? CurrentScenery { get; set; }
- public Trainset? CurrentTrainset { get; set; }
-}
diff --git a/StarterNG/Classes/ConfigFile.cs b/StarterNG/Classes/ConfigFile.cs
index f38895d..e551b2c 100644
--- a/StarterNG/Classes/ConfigFile.cs
+++ b/StarterNG/Classes/ConfigFile.cs
@@ -5,35 +5,17 @@ using System.Text;
namespace StarterNG.Classes;
-///
-/// A line-oriented reader/writer for the EU07 eu07.ini configuration
-/// file.
-///
-/// The file is not a classic INI: it is a flat list of key value [value…]
-/// entries, one per line, with // comments (either trailing a line or
-/// commenting the whole line out). The simulator parses it as a stream of
-/// whitespace-separated tokens.
-///
-/// This class keeps every line verbatim and only rewrites the value of a key
-/// when is called. Comments, blank lines, commented-out
-/// entries and — crucially — any keys the launcher does not understand are
-/// preserved on save, so editing a handful of settings never drops the rest of
-/// the user's configuration.
-///
public sealed class ConfigFile
{
private readonly List _lines = new();
- // Active (uncommented) setting key -> index into _lines. Last occurrence wins,
- // matching the simulator which lets later entries override earlier ones.
private readonly Dictionary _index =
new(StringComparer.OrdinalIgnoreCase);
- /// Parses raw file text, preserving line breaks and content.
public static ConfigFile Parse(string text)
{
var cfg = new ConfigFile();
- // Normalise CRLF/CR to LF for splitting; output uses Environment.NewLine.
+
text = text.Replace("\r\n", "\n").Replace('\r', '\n');
foreach (var line in text.Split('\n'))
cfg.AddLine(line);
@@ -45,13 +27,11 @@ public sealed class ConfigFile
int idx = _lines.Count;
_lines.Add(raw);
if (TryParseKey(raw, out string key))
- _index[key] = idx; // later duplicates override earlier ones
+ _index[key] = idx;
}
- /// True if the key is present as an active (uncommented) entry.
public bool Has(string key) => _index.ContainsKey(key);
- /// Raw value (all tokens after the key, comment stripped), or null.
public string? GetRaw(string key)
{
if (!_index.TryGetValue(key, out int i))
@@ -89,7 +69,6 @@ public sealed class ConfigFile
CultureInfo.InvariantCulture, out double d) ? d : fallback;
}
- /// All whitespace-separated value tokens for a key (never null).
public string[] GetTokens(string key)
{
var v = GetRaw(key);
@@ -98,11 +77,6 @@ public sealed class ConfigFile
: v.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
}
- ///
- /// Sets the value of . If the key already exists its
- /// line is rewritten in place, preserving original indentation and any
- /// trailing comment. Otherwise a new line is appended.
- ///
public void Set(string key, string value)
{
value ??= string.Empty;
@@ -125,11 +99,6 @@ public sealed class ConfigFile
}
}
- ///
- /// Deactivates by turning its line into a comment,
- /// preserving indentation, the key/value that was there and any trailing
- /// comment. No-op if the key is not currently active.
- ///
public void Remove(string key)
{
if (!_index.TryGetValue(key, out int i))
@@ -153,28 +122,19 @@ public sealed class ConfigFile
public void SetDouble(string key, double value) =>
Set(key, value.ToString("0.###", CultureInfo.InvariantCulture));
- /// Serialises back to text using the platform newline.
public string ToText() => string.Join(Environment.NewLine, _lines);
- // --- line parsing helpers -------------------------------------------------
-
private static bool TryParseKey(string raw, out string key)
{
SplitLine(raw, out _, out key, out _, out _);
return key.Length > 0;
}
- ///
- /// Decomposes a raw line into leading whitespace, key, value (tokens after
- /// the key) and trailing comment (including the //). For blank or
- /// fully commented-out lines, key is empty.
- ///
private static void SplitLine(string raw, out string indent, out string key,
out string value, out string comment)
{
indent = key = value = comment = string.Empty;
- // leading whitespace
int start = 0;
while (start < raw.Length && char.IsWhiteSpace(raw[start]))
start++;
@@ -182,7 +142,6 @@ public sealed class ConfigFile
string body = raw.Substring(start);
- // A line whose first non-space content is "//" is a pure comment.
if (body.StartsWith("//"))
{
comment = body;
@@ -191,8 +150,6 @@ public sealed class ConfigFile
if (body.Length == 0)
return;
- // Split off a trailing comment: the first "//" preceded by whitespace
- // (so URLs such as tcp://… inside a value are not mistaken for one).
int commentAt = FindCommentStart(body);
string content = commentAt >= 0 ? body.Substring(0, commentAt) : body;
if (commentAt >= 0)
diff --git a/StarterNG/Classes/GameData.cs b/StarterNG/Classes/GameData.cs
deleted file mode 100644
index ae17654..0000000
--- a/StarterNG/Classes/GameData.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-
-namespace StarterNG.Classes;
-
-public enum LoadPhase
-{
- Vehicles,
- Sceneries,
- Done
-}
-
-/// A single progress update emitted while the game data loads.
-public readonly record struct LoadStatus(double Fraction, LoadPhase Phase, string? Detail);
-
-///
-/// Loads and holds the data shared across the launcher (vehicle database and
-/// parsed sceneries) so it is parsed once, at startup, behind the splash —
-/// instead of inside each view's constructor.
-///
-public sealed class GameData
-{
- public static GameData Instance { get; } = new();
-
- public VehicleDatabase Vehicles { get; } = new();
- public List Sceneries { get; } = new();
-
- public bool Loaded { get; private set; }
-
- ///
- /// Parses the vehicle database and all sceneries, reporting progress.
- /// Runs synchronously; call it from a background thread. Never throws.
- ///
- public void Load(IProgress? progress = null,
- string vehiclesDir = "databases/vehicles/",
- string sceneryDir = "scenery/",
- string miniDir = "textures/mini/")
- {
- if (Loaded)
- return;
-
- var vehicleFiles = VehicleDatabase.EnumerateFiles(vehiclesDir);
- var scnFiles = EnumerateScenery(sceneryDir);
- int total = Math.Max(1, vehicleFiles.Count + scnFiles.Count);
- int done = 0;
-
- // Phase 1 - vehicle database
- Vehicles.BeginLoad();
- foreach (string file in vehicleFiles)
- {
- progress?.Report(new LoadStatus((double)done / total, LoadPhase.Vehicles,
- Path.GetFileName(file)));
- try { Vehicles.LoadFile(file); }
- catch { /* skip malformed file */ }
- done++;
- }
- Vehicles.EndLoad();
-
- // Preload the miniature .bmp index now (behind the splash) so the depot's
- // first thumbnail render doesn't scan textures/mini/ on the UI thread.
- VehicleDatabase.PreloadMiniIndex(miniDir);
-
- // Likewise index every .fiz under dynamic/ once, so the consist's physics
- // (length / mass / couplings) resolve without a UI-thread directory scan.
- Physics.PreloadIndex();
-
- // Phase 2 - sceneries
- foreach (string file in scnFiles)
- {
- progress?.Report(new LoadStatus((double)done / total, LoadPhase.Sceneries,
- Path.GetFileName(file)));
- try { Sceneries.Add(new Scenery(file)); }
- catch { /* skip unreadable scenery */ }
- done++;
- }
-
- progress?.Report(new LoadStatus(1.0, LoadPhase.Done, null));
- Loaded = true;
- }
-
- 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();
- }
-}
diff --git a/StarterNG/Classes/KeyMap.cs b/StarterNG/Classes/KeyMap.cs
index 7fdba7b..741c606 100644
--- a/StarterNG/Classes/KeyMap.cs
+++ b/StarterNG/Classes/KeyMap.cs
@@ -4,16 +4,11 @@ using Avalonia.Input;
namespace StarterNG.Classes;
-///
-/// One cap on the on-screen keyboard. is the simulator key
-/// token a binding would reference; when null the cap is a non-bindable filler
-/// (Shift, Ctrl, Tab …) drawn only to keep the layout recognisable.
-///
public sealed class KeyCap
{
public string Label = string.Empty;
- public string? Token; // null => filler / not bindable
- public double Width = 1.0; // relative width (1 == a standard key)
+ public string? Token;
+ public double Width = 1.0;
public KeyCap(string label, string? token = null, double width = 1.0)
{
@@ -23,20 +18,12 @@ public sealed class KeyCap
}
}
-///
-/// Translates between physical keyboard input, simulator key tokens and the
-/// on-screen keyboard layout used by the controls visualisation.
-///
public static class KeyMap
{
- ///
- /// Resolves a pressed key to its simulator token, using the physical key to
- /// disambiguate the numeric keypad. Returns null for keys with no token
- /// (e.g. bare modifiers).
- ///
+
public static string? FromInput(Key key, PhysicalKey physical)
{
- // Numeric keypad first - distinguished only by the physical key.
+
switch (physical)
{
case PhysicalKey.NumPad0: return "num_0";
@@ -57,15 +44,12 @@ public static class KeyMap
case PhysicalKey.NumPadEnter: return "num_enter";
}
- // Numeric keypad via the virtual key (fallback when PhysicalKey is Unknown).
if (key >= Key.NumPad0 && key <= Key.NumPad9)
return "num_" + (char)('0' + (key - Key.NumPad0));
- // Letters.
if (key >= Key.A && key <= Key.Z)
return ((char)('a' + (key - Key.A))).ToString();
- // Top-row digits.
if (key >= Key.D0 && key <= Key.D9)
return ((char)('0' + (key - Key.D0))).ToString();
@@ -98,14 +82,12 @@ public static class KeyMap
};
}
- /// True for keys that only act as modifiers and never form a binding alone.
public static bool IsModifierKey(Key key) => key is
Key.LeftShift or Key.RightShift or
Key.LeftCtrl or Key.RightCtrl or
Key.LeftAlt or Key.RightAlt or
Key.LWin or Key.RWin;
- /// Friendly, explicit name for a token, used in the binding list text.
public static string DisplayName(string? token)
{
if (string.IsNullOrEmpty(token) ||
@@ -144,14 +126,9 @@ public static class KeyMap
};
}
- // ── On-screen keyboard layout ──────────────────────────────────────────
- // Filler caps (Token == null) keep the shape familiar; only caps with a
- // token are coloured by assignment state.
-
- /// Rows of the main (alphanumeric) keyboard block.
public static readonly KeyCap[][] MainBlock =
{
- // F-row keys are reserved by the simulator, so they carry no token.
+
new[]
{
new KeyCap("Esc", null, 1.4),
@@ -205,7 +182,6 @@ public static class KeyMap
},
};
- /// Rows of the numeric-keypad block, drawn to the right of the main block.
public static readonly KeyCap[][] NumpadBlock =
{
new[]
@@ -234,10 +210,9 @@ public static class KeyMap
},
};
- /// Navigation cluster (Insert/Home/PageUp · Delete/End/PageDown), drawn between the blocks.
public static readonly KeyCap[][] NavBlock =
{
- // PgUp / PgDn are reserved by the simulator (no token), like [ ] and Enter.
+
new[]
{
new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"),
diff --git a/StarterNG/Classes/KeyboardConfig.cs b/StarterNG/Classes/KeyboardConfig.cs
index 840f7b5..b717853 100644
--- a/StarterNG/Classes/KeyboardConfig.cs
+++ b/StarterNG/Classes/KeyboardConfig.cs
@@ -2,63 +2,37 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
+using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Classes;
-///
-/// A single keyboard command binding parsed from eu07_input-keyboard.ini.
-/// A binding is a simulator command name, an optional pair of modifier flags
-/// (shift / ctrl, either or both) and the key it is mapped to.
-/// none (or an empty key) means the command is intentionally unbound.
-///
public sealed class KeyBinding
{
- /// Simulator command token, e.g. mastercontrollerincrease.
public string Command = string.Empty;
- /// True when the binding requires the Shift modifier.
public bool Shift;
- /// True when the binding requires the Ctrl modifier.
public bool Ctrl;
- /// The key token (e.g. q, num_+) or none when unbound.
public string Key = "none";
- /// Human description taken from the line's trailing comment (no //).
public string Description = string.Empty;
- /// Index into the backing line list, so the original line can be rewritten in place.
internal int LineIndex = -1;
- /// True when the command is mapped to an actual key.
public bool IsAssigned =>
!string.IsNullOrEmpty(Key) &&
!Key.Equals("none", StringComparison.OrdinalIgnoreCase);
}
-///
-/// Reader/writer for the simulator's keyboard layout file,
-/// eu07_input-keyboard.ini, which lives next to eu07.ini
-/// (%APPDATA%\MaSzyna\ on Windows, ~/.config/MaSzyna/ elsewhere).
-///
-/// The file is a flat list of command [shift] [ctrl] key // comment
-/// lines. Modifiers are the literal tokens shift and ctrl appearing
-/// in any order between the command and the key; the last value token is the key.
-/// Blank lines, pure comments and any lines the launcher does not recognise are
-/// preserved verbatim on save - only the binding lines the user actually edits
-/// are rewritten.
-///
public sealed class KeyboardConfig
{
public static KeyboardConfig Instance { get; } = new();
private readonly List _lines = new();
- /// All recognised bindings, in file order.
public List Bindings { get; private set; } = new();
- /// True when a binding has been changed since the last load/save.
public bool Dirty { get; set; }
private string _savePath = string.Empty;
@@ -67,11 +41,10 @@ public sealed class KeyboardConfig
private static Encoding ResolveEncoding()
{
- // Comments use Polish diacritics in Windows-1250, like eu07.ini; preserve them.
+
try
{
- Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
- return Encoding.GetEncoding(1250);
+ return LegacyText.CodePage1250;
}
catch
{
@@ -79,7 +52,6 @@ public sealed class KeyboardConfig
}
}
- /// Per-user keyboard config path for the current OS (next to eu07.ini).
public static string UserConfigPath()
{
if (OperatingSystem.IsWindows())
@@ -103,15 +75,12 @@ public sealed class KeyboardConfig
return Path.Combine(AppContext.BaseDirectory, "eu07_input-keyboard.ini");
}
- /// Default file shipped in the simulator's working directory.
private static string WorkingDirDefaultPath() =>
Path.Combine(Directory.GetCurrentDirectory(), "eu07_input-keyboard.ini");
- /// Fallback template bundled with the launcher (startercfg/).
private static string BundledDefaultPath() =>
Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini");
- /// Loads the per-user file, falling back to the working-dir or bundled default.
public void Load()
{
_savePath = UserConfigPath();
@@ -120,7 +89,6 @@ public sealed class KeyboardConfig
Dirty = false;
}
- /// Reloads the shipped defaults, discarding the user's current edits.
public void LoadDefaults()
{
if (string.IsNullOrEmpty(_savePath))
@@ -151,7 +119,6 @@ public sealed class KeyboardConfig
return paths.Length > 0 ? paths[^1] : string.Empty;
}
- /// Parses raw file text into , keeping every line verbatim.
public void Parse(string text)
{
_lines.Clear();
@@ -170,10 +137,6 @@ public sealed class KeyboardConfig
Bindings = bindings;
}
- ///
- /// Parses a single line into a binding. Returns false for blank lines, pure
- /// comments and anything without a command token.
- ///
private static bool TryParseBinding(string raw, int lineIndex, out KeyBinding binding)
{
binding = new KeyBinding { LineIndex = lineIndex };
@@ -181,7 +144,6 @@ public sealed class KeyboardConfig
string body = raw;
string description = string.Empty;
- // Split off a trailing "// comment".
int commentAt = body.IndexOf("//", StringComparison.Ordinal);
if (commentAt >= 0)
{
@@ -191,7 +153,7 @@ public sealed class KeyboardConfig
body = body.Trim();
if (body.Length == 0)
- return false; // blank or pure-comment line
+ return false;
var tokens = body.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length == 0)
@@ -200,7 +162,6 @@ public sealed class KeyboardConfig
binding.Command = tokens[0];
binding.Description = description;
- // Everything after the command is modifiers + (at most) one key.
bool shift = false, ctrl = false;
string key = "none";
for (int i = 1; i < tokens.Length; i++)
@@ -208,7 +169,7 @@ public sealed class KeyboardConfig
string t = tokens[i];
if (t.Equals("shift", StringComparison.OrdinalIgnoreCase)) shift = true;
else if (t.Equals("ctrl", StringComparison.OrdinalIgnoreCase)) ctrl = true;
- else key = t; // last non-modifier token wins as the key
+ else key = t;
}
binding.Shift = shift;
@@ -217,7 +178,6 @@ public sealed class KeyboardConfig
return true;
}
- /// Rewrites each binding's line, then serialises the file to text.
public string ToText()
{
foreach (var b in Bindings)
@@ -229,7 +189,6 @@ public sealed class KeyboardConfig
return string.Join(Environment.NewLine, _lines);
}
- /// Formats one binding as command [shift] [ctrl] key // comment.
private static string FormatLine(KeyBinding b)
{
var sb = new StringBuilder();
@@ -243,7 +202,6 @@ public sealed class KeyboardConfig
return sb.ToString();
}
- /// Writes the bindings back to the per-user keyboard file.
public void Save()
{
if (string.IsNullOrEmpty(_savePath))
@@ -257,9 +215,9 @@ public sealed class KeyboardConfig
File.WriteAllText(_savePath, ToText(), FileEncoding);
Dirty = false;
}
- catch
+ catch (Exception ex)
{
- // Could not write (permissions / path). Leave values in memory.
+ StarterNG.Infrastructure.Diagnostics.Log($"saving {_savePath}", ex);
}
}
}
diff --git a/StarterNG/Classes/Physics.cs b/StarterNG/Classes/Physics.cs
deleted file mode 100644
index 3d65f63..0000000
--- a/StarterNG/Classes/Physics.cs
+++ /dev/null
@@ -1,230 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-
-namespace StarterNG.Classes;
-
-///
-/// Vehicle physics read from a .fiz file (dynamic/<dir>/<model>.fiz), mirroring
-/// the original Starter's TLexParser.ParsePhysics. Provides mass, top speed,
-/// length, accepted loads and the coupler AllowedFlag / ControlType used for the
-/// automatic coupling calculation.
-///
-public sealed class Physics
-{
- public double Mass; // kg, as written in the .fiz
- public double VMax; // km/h
- public double Length; // metres
- public string LoadAccepted = "";
- public int MaxLoad;
- public int AllowedFlagA = 3;
- public int AllowedFlagB = 3;
- public string ControlTypeA = "";
- public string ControlTypeB = "";
-
- private static readonly Dictionary Cache = new(StringComparer.OrdinalIgnoreCase);
-
- // Index of every .fiz under dynamic/, keyed by its file name without extension
- // (e.g. "en57", "en57dumb"). Built once - the directory layout of the data
- // folder vs the scenery's mmd token is unreliable, so we resolve by model name.
- private static Dictionary? _fizIndex;
-
- /// Builds the .fiz index up front (call from the startup load).
- public static void PreloadIndex(string dynamicRoot = "dynamic") => FizIndex(dynamicRoot);
-
- private static Dictionary FizIndex(string dynamicRoot)
- {
- if (_fizIndex != null)
- return _fizIndex;
-
- var index = new Dictionary(StringComparer.OrdinalIgnoreCase);
- try
- {
- if (Directory.Exists(dynamicRoot))
- foreach (string f in Directory.EnumerateFiles(dynamicRoot, "*.fiz", SearchOption.AllDirectories))
- index[Path.GetFileNameWithoutExtension(f)] = f;
- }
- catch { /* unreadable dynamic/ - no physics */ }
-
- _fizIndex = index;
- return index;
- }
-
- ///
- /// Loads (and caches) the physics for a model name (the .fiz is located by name
- /// anywhere under dynamic/). The is unused now but
- /// kept for call-site clarity. Returns null when nothing matches. Never throws.
- ///
- public static Physics? For(string? dataFolder, string? model, string dynamicRoot = "dynamic")
- {
- if (string.IsNullOrEmpty(model))
- return null;
-
- string m = Path.GetFileNameWithoutExtension(model!); // strip any .mmd/.t3d/etc.
- if (m.Length == 0)
- return null;
-
- if (Cache.TryGetValue(m, out var cached))
- return cached;
-
- var index = FizIndex(dynamicRoot);
- string? path = null;
- if (index.TryGetValue(m, out var p1)) path = p1;
- else if (index.TryGetValue(m + "dumb", out var p2)) path = p2;
-
- Physics? phys = null;
- if (path != null)
- {
- phys = new Physics { AllowedFlagB = -1 }; // -1 = "B end not declared yet"
- try { Parse(phys, path, Path.GetDirectoryName(path) ?? "", null, 0); }
- catch { /* partial data still useful */ }
-
- // The original copies the A-end coupler onto the B-end when only one
- // BuffCoupl section is declared.
- if (phys.AllowedFlagB == -1)
- {
- phys.AllowedFlagB = phys.AllowedFlagA;
- if (string.IsNullOrEmpty(phys.ControlTypeB))
- phys.ControlTypeB = phys.ControlTypeA;
- }
- }
-
- Cache[m] = phys;
- return phys;
- }
-
- private static readonly HashSet KnownKeys = new(StringComparer.OrdinalIgnoreCase)
- {
- "M", "Vmax", "L", "LoadAccepted", "MaxLoad", "AllowedFlag", "ControlType"
- };
-
- private static void Parse(Physics p, string path, string dir, string[]? prms, int depth)
- {
- if (depth > 6 || !File.Exists(path))
- return;
-
- var tokens = Tokenize(File.ReadAllText(path, Encoding.GetEncoding(1250)));
-
- string section = "";
- string? pendingKey = null;
-
- for (int i = 0; i < tokens.Count; i++)
- {
- string tok = tokens[i];
- if (tok == "=")
- continue;
-
- // include end -> parse the included physics
- if (tok.Equals("include", StringComparison.OrdinalIgnoreCase))
- {
- if (++i >= tokens.Count) break;
- string incFile = Resolve(tokens[i], prms);
- var incParams = new List();
- i++;
- while (i < tokens.Count && !tokens[i].Equals("end", StringComparison.OrdinalIgnoreCase))
- incParams.Add(Resolve(tokens[i++], prms));
- Parse(p, Path.Combine(dir, incFile), dir, incParams.ToArray(), depth + 1);
- pendingKey = null;
- continue;
- }
-
- int eq = tok.IndexOf('=');
- if (eq >= 0)
- {
- string k = tok[..eq].TrimStart('.').Trim();
- string v = tok[(eq + 1)..].Trim();
- if (v.Length == 0) { pendingKey = k; continue; }
- Apply(p, section, k, Resolve(v, prms));
- pendingKey = null;
- continue;
- }
-
- string t = tok.TrimStart('.').Trim();
- if (pendingKey != null)
- {
- Apply(p, section, pendingKey, Resolve(t, prms));
- pendingKey = null;
- }
- else if (KnownKeys.Contains(t))
- {
- pendingKey = t; // value comes in a following token (spaced "Key = value")
- }
- else
- {
- // any other bare identifier is a section header (Param, Load,
- // Dimensions, BuffCoupl/1/2, or one we don't read). Keep only
- // letters/digits so "Param:" or "Param," still match.
- section = new string(t.Where(char.IsLetterOrDigit).ToArray()).ToLowerInvariant();
- }
- }
- }
-
- private static void Apply(Physics p, string section, string key, string value)
- {
- switch (section)
- {
- case "param":
- if (key.Equals("M", StringComparison.OrdinalIgnoreCase)) p.Mass = ParseD(value, p.Mass);
- else if (key.Equals("Vmax", StringComparison.OrdinalIgnoreCase)) p.VMax = ParseD(value, p.VMax);
- break;
- case "load":
- if (key.Equals("LoadAccepted", StringComparison.OrdinalIgnoreCase)) p.LoadAccepted = value;
- else if (key.Equals("MaxLoad", StringComparison.OrdinalIgnoreCase)) p.MaxLoad = (int)ParseD(value, p.MaxLoad);
- break;
- case "dimensions":
- if (key.Equals("L", StringComparison.OrdinalIgnoreCase)) p.Length = ParseD(value, p.Length);
- break;
- case "buffcoupl":
- case "buffcoupl1":
- if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagA = Flag(value, p.AllowedFlagA);
- else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeA = value;
- break;
- case "buffcoupl2":
- if (key.Equals("AllowedFlag", StringComparison.OrdinalIgnoreCase)) p.AllowedFlagB = Flag(value, p.AllowedFlagB);
- else if (key.Equals("ControlType", StringComparison.OrdinalIgnoreCase)) p.ControlTypeB = value;
- break;
- }
- }
-
- // A negative AllowedFlag means a permanent coupling: abs + 128 (workshop-lock bit).
- private static int Flag(string v, int fallback)
- => int.TryParse(v, System.Globalization.NumberStyles.Integer,
- System.Globalization.CultureInfo.InvariantCulture, out int f)
- ? (f < 0 ? -f + 128 : f)
- : fallback;
-
- private static double ParseD(string v, double fallback)
- => double.TryParse(v, System.Globalization.NumberStyles.Float,
- System.Globalization.CultureInfo.InvariantCulture, out double d)
- ? d
- : fallback;
-
- // Substitutes an include placeholder like "(c1)" with the matching include
- // parameter (1-based), otherwise returns the token unchanged.
- private static string Resolve(string token, string[]? prms)
- {
- if (prms == null || token.Length < 3 || token[0] != '(' || token[^1] != ')')
- return token;
- string digits = new string(token.Where(char.IsDigit).ToArray());
- return int.TryParse(digits, out int idx) && idx >= 1 && idx <= prms.Length
- ? prms[idx - 1]
- : token;
- }
-
- private static List Tokenize(string text)
- {
- var sb = new StringBuilder();
- foreach (var line in text.Split('\n'))
- {
- string l = line;
- int hash = l.IndexOf('#');
- if (hash >= 0) l = l[..hash];
- sb.Append(l).Append(' ');
- }
- return sb.ToString()
- .Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
- .ToList();
- }
-}
diff --git a/StarterNG/Classes/PresetStore.cs b/StarterNG/Classes/PresetStore.cs
index 9d9ce65..b03a748 100644
--- a/StarterNG/Classes/PresetStore.cs
+++ b/StarterNG/Classes/PresetStore.cs
@@ -1,37 +1,35 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
+using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
+using System.Text.RegularExpressions;
+using StarterNG.Infrastructure.Adapters;
namespace StarterNG.Classes;
-/// One saved consist in the user's vehicle warehouse.
+public enum PresetSort
+{
+ Name,
+ Track
+}
+
public sealed class TrainsetPreset
{
- /// Display name shown in the "load from warehouse" menu.
public string Name { get; set; } = "";
- /// Complete "trainset … endtrainset" scenery entry (self-contained).
public string Entry { get; set; } = "";
}
-/// Root object persisted to userpresets.json.
public sealed class PresetCollection
{
public List Presets { get; set; } = new();
}
-///
-/// The user's vehicle/consist "warehouse": named trainset presets persisted as JSON.
-/// Stored under the per-user config directory so it survives reinstalls:
-/// Windows: %APPDATA%\MaSzyna\starter\userpresets.json
-/// macOS: ~/Library/Application Support/MaSzyna/starter/userpresets.json
-/// Linux: ~/.config/MaSzyna/starter/userpresets.json
-/// All operations are best-effort and never throw.
-///
public static class PresetStore
{
private static string ResolvePath()
@@ -54,8 +52,8 @@ public static class PresetStore
public static string FilePath { get; } = ResolvePath();
- // Source-generated metadata keeps (de)serialisation AOT/trim-safe, matching the
- // approach used for the vehicle database.
+ public static PresetSort SortMode { get; set; } = PresetSort.Name;
+
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
@@ -65,13 +63,16 @@ public static class PresetStore
private static JsonTypeInfo TypeInfo =>
(JsonTypeInfo)Options.GetTypeInfo(typeof(PresetCollection));
- /// All saved presets, sorted by name. Empty when none / unreadable.
- public static IReadOnlyList All() => Load().Presets;
+ public static IReadOnlyList All()
+ {
+ var list = Load().Presets.ToList();
+ if (SortMode == PresetSort.Track)
+ list.Sort((a, b) => string.Compare(TrackOf(a), TrackOf(b), StringComparison.OrdinalIgnoreCase));
+ else
+ list.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
+ return list;
+ }
- ///
- /// Saves (or overwrites, by name) a consist preset. No-op on a blank name or
- /// empty entry.
- ///
public static void Save(string? name, string? entry)
{
name = name?.Trim() ?? "";
@@ -85,7 +86,6 @@ public static class PresetStore
Persist(col);
}
- /// Removes a preset by name (case-insensitive).
public static void Delete(string name)
{
var col = Load();
@@ -93,6 +93,76 @@ public static class PresetStore
Persist(col);
}
+ public static int ImportMagazyn(string? path = null)
+ {
+ path ??= FindMagazynPath();
+ if (path is null || !File.Exists(path)) return 0;
+
+ int added = 0;
+ try
+ {
+ string text = File.ReadAllText(path, LegacyText.CodePage1250);
+ var section = Regex.Matches(text,
+ @"\[TRAINSET\d+\s*=\s*([^\]]*)\](.*?)(?=\[TRAINSET|\z)",
+ RegexOptions.IgnoreCase | RegexOptions.Singleline);
+
+ foreach (Match m in section)
+ {
+ string name = m.Groups[1].Value.Trim();
+ if (name.Length == 0) name = $"import_{added + 1}";
+
+ var nodes = new List();
+ foreach (Match line in Regex.Matches(m.Groups[2].Value, @"(?m)^\s*\d+\s*=\s*(.+)$"))
+ {
+ string body = line.Groups[1].Value.Trim();
+ if (!body.Contains("enddynamic", StringComparison.OrdinalIgnoreCase))
+ body += " enddynamic";
+ nodes.Add(body);
+ }
+ if (nodes.Count == 0) continue;
+
+ var sb = new StringBuilder();
+ sb.Append("trainset ").Append(Sanitize(name)).Append(" none 0 0\n");
+ foreach (string n in nodes)
+ sb.Append(n).Append('\n');
+ sb.Append("endtrainset\n");
+
+ Save(name, sb.ToString());
+ added++;
+ }
+ }
+ catch (Exception ex)
+ {
+ Infrastructure.Diagnostics.Log($"Import {path}", ex);
+ }
+ return added;
+ }
+
+ private static string? FindMagazynPath()
+ {
+ string cwd = Directory.GetCurrentDirectory();
+ foreach (string rel in new[]
+ {
+ Path.Combine("starter", "magazyn.ini"),
+ "starter.ini",
+ "RAINSTED.INI"
+ })
+ {
+ string p = Path.Combine(cwd, rel);
+ if (File.Exists(p)) return p;
+ }
+ return null;
+ }
+
+ private static string TrackOf(TrainsetPreset p)
+ {
+ var m = Regex.Match(p.Entry ?? "", @"^\s*trainset\s+\S+\s+(\S+)", RegexOptions.Multiline | RegexOptions.IgnoreCase);
+ return m.Success ? m.Groups[1].Value : "";
+ }
+
+ private static string Sanitize(string name) =>
+ string.IsNullOrWhiteSpace(name) ? "preset" : name.Replace(' ', '_');
+
private static PresetCollection Load()
{
try
@@ -104,9 +174,9 @@ public static class PresetStore
return col;
}
}
- catch
+ catch (Exception ex)
{
- // unreadable / malformed - start from an empty warehouse
+ StarterNG.Infrastructure.Diagnostics.Log($"reading {FilePath}", ex);
}
return new PresetCollection();
}
@@ -118,26 +188,17 @@ public static class PresetStore
Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!);
File.WriteAllText(FilePath, JsonSerializer.Serialize(col, TypeInfo));
}
- catch
+ catch (Exception ex)
{
- // best-effort: a failed save just means the warehouse isn't updated
+ StarterNG.Infrastructure.Diagnostics.Log($"saving {FilePath}", ex);
}
}
}
-///
-/// Helpers to convert a consist to/from its canonical scenery text - the full
-/// "trainset … endtrainset" block, exactly what is placed on the clipboard.
-///
public static class ConsistText
{
- /// The complete scenery entry for a trainset (incl. endtrainset).
public static string Serialize(Trainset trainset) => trainset.ToSceneryEntry();
- ///
- /// Parses the vehicles out of a complete trainset entry. Returns null when the
- /// text holds no usable node::dynamic vehicles.
- ///
public static List? VehiclesFrom(string? text)
{
if (string.IsNullOrWhiteSpace(text))
@@ -154,7 +215,6 @@ public static class ConsistText
}
}
-// Source-generated JSON metadata (AOT/trim-safe; no runtime reflection).
[JsonSerializable(typeof(PresetCollection))]
[JsonSerializable(typeof(TrainsetPreset))]
internal partial class PresetJsonContext : JsonSerializerContext
diff --git a/StarterNG/Classes/Scenery.cs b/StarterNG/Classes/Scenery.cs
deleted file mode 100644
index bcf3583..0000000
--- a/StarterNG/Classes/Scenery.cs
+++ /dev/null
@@ -1,283 +0,0 @@
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-using System.Text.RegularExpressions;
-
-namespace StarterNG.Classes;
-
-public class Scenery
-{
- public List Lines;
- public List Trainsets;
- public string Group;
- public string Path;
-
- // Starter directives (comment-syntax metadata, see wiki "Plik scenerii").
- // They do not affect the simulation, only how a starter presents the scenery.
- public string Name; // //$n - scenery name
- public string Description; // //$d - scenery description
- public string ImageName; // //$i - main-window image (scenery thumbnail)
-
- /// True when the scenery declares the //$a "archival" flag.
- public bool Archival;
-
- // Weather / environment. Like the original Starter, these are editable and are
- // written into the scenery's "config" block on launch (see RewriteWeather).
- // Defaults mirror the original (15 °C, day 0, 10:30, clear sky).
- public string WeatherTime = "10:30"; // h:mm -> "time"/"scenario.time.override"
- public int Day = 0; // "movelight " (day of year / season)
- public double Temperature = 15; // "scenario.weather.temperature"
- public int FogEnd = 2000; // visibility in metres (atmo fog range)
- public double Overcast = 0; // atmo overcast factor (-1.5 .. 1.5)
-
- /// True when the scenery actually declared any weather command.
- public bool HasWeather;
-
- /// Set once the user edits the weather, so export rewrites the config.
- public bool WeatherDirty;
-
- // The file content with each trainset block replaced by a {{i}} placeholder,
- // used to rebuild the .scn on export.
- private readonly string _template;
-
- // Lazily resolved, cached path to the //$i image on disk (null if not found).
- 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); // Windows-1250
- string content = File.ReadAllText(path, encoding);
-
- // property scanning - starter directives written as // comments.
- // \b after the letter keeps //$d from matching //$decor, //$i from
- // matching //$it, etc.
- this.Group = MatchDirective(content, "l");
- this.Name = MatchDirective(content, "n");
- // //$d may appear on several lines; each is one line of the description.
- this.Description = MatchAllDirectives(content, "d");
- this.ImageName = MatchDirective(content, "i");
- // //$a marks an archival scenery (present = archival, regardless of value).
- this.Archival = MatchDirective(content, "a") != null;
-
- // weather/environment is read from the raw text before trainset blocks
- // are stripped out below (the atmosphere commands live outside trainsets)
- ParseWeather(content);
-
-
- // parsing trainsets
- 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++}}}}}";
- });
- _template = content;
-
- // 1:1 with placeholders - the Trainset ctor never throws (unparsable
- // blocks are kept verbatim), so indices stay aligned for export.
- foreach (string trainsetEntry in trainsetEntries)
- Trainsets.Add(new Trainset(trainsetEntry));
- }
-
- ///
- /// Rebuilds the full .scn content with the (possibly modified) trainsets
- /// substituted back into their original positions.
- ///
- public string BuildExportContent(bool skipDecorTrainsets = false)
- {
- string result = _template;
-
- // Inject the (edited) weather into the config block before substituting
- // trainsets, so the {{i}} placeholders shield the trainset text from the
- // weather rewrite. Only done when the user actually changed the weather.
- if (WeatherDirty)
- result = RewriteWeather(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);
- }
- return result;
- }
-
- ///
- /// Reads the scenery's environment commands into the editable weather fields.
- /// Mirrors the original Starter (config: movelight / scenario.weather.temperature
- /// / scenario.time.override, plus top-level time and the atmo fog/overcast block).
- ///
- private void ParseWeather(string content)
- {
- // start time: "scenario.time.override h:mm" wins, else top-level "time h:mm"
- var ovr = Regex.Match(content, @"(?i)scenario\.time\.override\s+(\d{1,2})[:.](\d{2})");
- var time = ovr.Success
- ? ovr
- : Regex.Match(content, @"(?im)^\s*time\s+(\d{1,2})[:.](\d{2})\b");
- if (time.Success)
- {
- WeatherTime = $"{time.Groups[1].Value.PadLeft(2, '0')}:{time.Groups[2].Value}";
- HasWeather = true;
- }
-
- // "movelight " - day of the year (sun elevation / season)
- 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;
- }
-
- // "scenario.weather.temperature <°C>"
- 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;
- }
-
- // "atmo R G B fogStart fogEnd R G B overcast endatmo"
- 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[4], out int fog))
- {
- FogEnd = fog;
- HasWeather = true;
- }
- if (nums.Count >= 6 &&
- double.TryParse(nums[^1], System.Globalization.NumberStyles.Float,
- System.Globalization.CultureInfo.InvariantCulture, out double oc))
- Overcast = oc;
- }
- }
-
- ///
- /// Replaces the scenery's environment commands with a fresh config block built
- /// from the current weather fields (the C# equivalent of the original Starter's
- /// TLexParser.ChangeConfig). On any failure the original text is returned
- /// unchanged so a launch is never blocked by a bad rewrite.
- ///
- private string RewriteWeather(string text)
- {
- try
- {
- // strip the existing weather commands
- string s = text;
- 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 {WeatherTime}\r\n" +
- $"time {WeatherTime} 0 0 endtime\r\n" +
- $"atmo 0 0 0 {FogEnd} {FogEnd} 0 0 0 {Overcast.ToString(inv)} endatmo\r\n" +
- "endconfig\r\n";
-
- return config + s;
- }
- catch
- {
- return text;
- }
- }
-
- ///
- /// Collects every //$<letter> line (e.g. all //$d lines) and joins them
- /// into one multi-line string. Returns null when none are present.
- ///
- 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()));
- }
-
- ///
- /// Reads a single starter directive value (the text after //$<letter>).
- /// Returns null when the directive is absent. The trailing whitespace
- /// requirement separates e.g. //$d from //$decor and //$i from //$it.
- ///
- 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;
- }
-
- ///
- /// Resolved on-disk path of the //$i scenery image, or null if none is
- /// declared or the file cannot be found. The value of //$i may be a bare
- /// file name or a path; common locations are probed and the result cached.
- ///
- public string ImagePath
- {
- get
- {
- if (_imagePathResolved)
- return _imagePath;
- _imagePathResolved = true;
- _imagePath = ResolveImagePath();
- return _imagePath;
- }
- }
-
- private string ResolveImagePath()
- {
- if (string.IsNullOrWhiteSpace(ImageName))
- return null;
-
- // normalise legacy back-slashes so paths work cross-platform
- string name = ImageName.Replace('\\', '/').Trim();
- string scnDir = System.IO.Path.GetDirectoryName(Path) ?? "."; // e.g. scenery/
- string root = System.IO.Path.GetDirectoryName(scnDir) ?? "."; // MaSzyna root
-
- // probe the usual places, first hit wins
- var candidates = new List
- {
- name, // as given (cwd / absolute)
- System.IO.Path.Combine(root, name), // relative to MaSzyna root
- System.IO.Path.Combine(scnDir, name), // next to the .scn
- System.IO.Path.Combine(scnDir, "images", name), // scenery/images/
- System.IO.Path.Combine(root, "scenery", "images", name), // scenery/images/ from root
- };
-
- foreach (string candidate in candidates)
- {
- if (File.Exists(candidate))
- return candidate;
- }
- return null;
- }
-}
diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs
deleted file mode 100644
index 1275cb9..0000000
--- a/StarterNG/Classes/Settings.cs
+++ /dev/null
@@ -1,608 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Text;
-
-namespace StarterNG.Classes;
-
-public enum BatteryDefault { Default = 0, AlwaysOff = 1, AlwaysOn = 2 }
-
-///
-/// Application settings backing the Settings view, persisted to the simulator's
-/// eu07.ini.
-///
-/// Where it loads/saves. The active file lives next to the
-/// simulator's own config: %APPDATA%\MaSzyna\eu07.ini on Windows or
-/// ~/.config/MaSzyna/eu07.ini elsewhere. If that file does not exist yet,
-/// defaults are read from an eu07.ini in the launcher's working directory
-/// (the same fallback the simulator uses). Saving always writes to the per-user
-/// path.
-///
-/// Unknown keys are kept. The whole file is round-tripped through
-/// ; only the keys the UI actually edits are rewritten,
-/// so any extra settings the launcher does not model survive a save.
-///
-/// Some mappings are best-effort where the wiki does not document an exact
-/// key; those are marked with NOTE and can be adjusted against the running exe.
-///
-public sealed class Settings
-{
- public static Settings Instance { get; } = new();
-
- ///
- /// Pulls the current values out of the Settings view into this instance.
- /// The view assigns this when it is constructed; null if the view was never
- /// opened (in which case Save just re-writes the loaded values).
- ///
- public Action? CaptureFromUi { get; set; }
-
- /// True if the loaded config explicitly contained a lang entry.
- public bool LanguageWasSet { get; private set; }
-
- // ── General ───────────────────────────────────────────────────────────
- public string Language = "English"; // lang (pl/en)
- public bool Fullscreen; // fullscreen
- public bool PauseWhenInactive = true; // inactivepause
- public bool PauseOnStart; // pause
- public int CursorSensitivity = 2; // |mousescale x|
- public bool InvertMouseHorizontal; // mousescale x < 0
- public bool InvertMouseVertical; // mousescale y < 0
-
- // ── Communication ─────────────────────────────────────────────────────
- public bool IgnoreGamepad; // input.gamepad (inverted)
- public int FeedbackMode = 1; // feedbackmode (0-5)
- public int FeedbackPort = 888; // feedbackport
- public bool UartEnabled; // uart presence
- public string UartPort = ""; // uart
- public string UartTune = ""; // uarttune
- public bool UartDebug; // uartdebug
- public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel; // uartfeature tokens
-
- // ── Other ─────────────────────────────────────────────────────────────
- public bool SelectExeAutomatically = true; // starter.exe.auto (launcher-only)
- public string ExecutablePath = "eu07.exe"; // starter.exe.path (launcher-only)
- public bool DebugMode; // debugmode
- public bool VirtualShunting = true; // ai.trainman
- public bool LogMissingVehicleFiles; // starter.logmissingvehicles (launcher-only; not yet wired to any load/export codepath)
-
- // ── Graphics ──────────────────────────────────────────────────────────
- public int RenderEngine; // gfxrenderer (index, see RenderEngines)
- public int Width = 1280; // width
- public int Height = 720; // height
- public int BufferScalePercent = 100; // starter.bufferscale (NOTE: launcher-only)
- public int MaxTextureSize = 4096; // maxtexturesize
- public int MaxCabTextureSize = 4096; // maxcabtexturesize
- public int TextureFiltering = 8; // anisotropicfiltering (1/2/4/8/16)
- public int Multisampling = 2; // multisampling (0-3)
- public int DynamicLights = 7; // dynamiclights (0-7)
- public double DrawRangeFactor = 3.0; // gfx.drawrange.factor.max
- public bool VSync; // vsync
- public bool Smoke = true; // gfx.smoke
- public int SmokeFidelity = 1; // gfx.smoke.fidelity (1-4)
- public bool ChromaticAberration = true; // gfx.postfx.chromaticaberration.enabled
- public bool MotionBlur = true; // gfx.postfx.motionblur.enabled
- public bool ExtraEffects = true; // gfx.extraeffects
- public bool EnvMap = true; // gfx.envmap.enabled
- public bool UseVbo = true; // usevbo
- public bool RenderShadows = true; // shadows + gfx.shadowmap.enabled
- public int ReflectionsFramerate = 60; // gfx.reflections.framerate
- public int ShadowMapResolution = 2048; // shadowtune[0]
- public int ShadowProjectionRange = 250; // shadowtune[1]
- public int CabShadowsRange; // gfx.shadows.cab.range
- public int ShadowRankCutoff = 3; // gfx.shadow.rank.cutoff (NOTE)
- public int ReflectionsFidelity; // gfx.reflections.fidelity (0-2)
- public int FieldOfView = 45; // fieldofview (15-75) NOTE: 'fovSlider' in XAML
- public bool PythonScreens = true; // python.enabled
- public bool PythonThreadedUpload = true; // python.threadedupload
- public bool FpsLimitEnabled; // fpslimit (presence)
- public int FpsLimit = 30; // fpslimit (1-100)
- public double ShadowAngleLimit = 0.2; // gfx.shadow.angle.min (0.2-1.0)
- public bool FullscreenWindowed; // fullscreenwindowed
- public bool RenderAngleVulkan; // gfx.angleplatform ("vulkan")
-
- // ── Physics ───────────────────────────────────────────────────────────
- public int SplineFidelity = 1; // splinefidelity (1-4)
- public bool FullPhysics = true; // fullphysics
- public bool EnableTraction = true; // enabletraction (pantograph can break)
- public bool LiveTraction = true; // livetraction
- public bool PhysicsLog; // physicslog (speedometer tapes)
- public bool DebugLog = true; // debuglog (3 / 0)
- public bool MultipleLogs; // multiplelogs
- public bool DisplaySimulation = true; // gfx.skiprendering (inverted)
- public bool CrashDamage = true; // crashdamage
- public double Friction = 1.0; // friction (0.1-3.0)
- public double BrakeStep = 1.0; // brakestep (0.4-3.0)
- public double BrakeSpeed = 3.0; // brakespeed (0.4-3.0)
-
- // ── Sound ─────────────────────────────────────────────────────────────
- public bool SoundEnabled = true; // soundenabled
- public int Volume = 50; // sound.volume (0-2 → 0-100)
- public int RadioVolume = 75; // sound.volume.radio (0-1 → 0-100)
- public int VehiclesVolume = 100; // sound.volume.vehicle
- public int PositionalVolume = 100; // sound.volume.positional
- public int AmbientVolume = 100; // sound.volume.ambient
-
- // ── Advanced ──────────────────────────────────────────────────────────
- public bool CompressTextures = true; // compresstex
- public bool ScaleSpeculars = true; // scalespeculars
- public bool UseGLES; // gfx.usegles
- public bool ShaderGamma; // gfx.shadergamma
- public bool ScreenMipmaps = true; // python.mipmaps
- public bool ExtendedModelConversion; // convertmodels ("143"/"0")
- public bool GfxResourceMove; // gfx.resource.move
- public bool GfxResourceSweep = true; // gfx.resource.sweep
- public int TrainPhysicsThreads; // async.trainThreads
- public bool IgnoreIrrelevantTrains; // starter.ignoreirrelevant (launcher-only)
-
- // ── Starter (launcher-only, stored under starter.* keys) ───────────────
- public bool AutoCloseStarter; // starter.autoclose
- public bool LargeThumbnails; // starter.largethumbnails
- public bool AutoExpandSceneryTree; // starter.expandtree
- public BatteryDefault BatteryDefault = BatteryDefault.Default; // starter.batterydefault (launcher-only)
-
- // List filters (launcher-only). Default on.
- public bool ShowAiVehicles = true; // starter.show.ai (show //$o "-" consists)
- public bool DrivableOnly = true; // starter.drivableonly (hide //$decor consists)
- public bool ShowArchivalSceneries = true; // starter.show.archival
-
- /// gfxrenderer tokens in the order shown by the render-engine combo.
- public static readonly string[] RenderEngines =
- { "full", "legacy", "simpleshader", "simple", "off", "experimental" };
-
- /// Anisotropic-filtering steps for the texture-quality slider (1-5).
- public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 };
-
- // Tokens of mousescale / shadowtune that the UI does not edit but must keep.
- private double _mouseScaleYMagnitude = 0.2;
- private string[] _shadowTuneExtra = { "400", "300" }; // tokens [2], [3]
-
- private ConfigFile _config = new();
- private string _savePath = string.Empty;
-
- /// Resolves the per-user eu07.ini path for the current OS.
- public static string UserConfigPath()
- {
- if (OperatingSystem.IsWindows())
- {
- string? appData = Environment.GetEnvironmentVariable("APPDATA");
- if (!string.IsNullOrEmpty(appData))
- return Path.Combine(appData, "MaSzyna", "eu07.ini");
- }
- else if (OperatingSystem.IsMacOS())
- {
- string? home = Environment.GetEnvironmentVariable("HOME");
- if (!string.IsNullOrEmpty(home))
- return Path.Combine(home, "Library", "Application Support", "MaSzyna", "eu07.ini");
- }
- else
- {
- string? home = Environment.GetEnvironmentVariable("HOME");
- if (!string.IsNullOrEmpty(home))
- return Path.Combine(home, ".config", "MaSzyna", "eu07.ini");
- }
- // Last resort: alongside the launcher.
- return Path.Combine(AppContext.BaseDirectory, "eu07.ini");
- }
-
- /// Default config shipped in the launcher's working directory.
- private static string DefaultConfigPath() =>
- Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini");
-
- /// Directory holding the per-user eu07.ini, for locating launcher-only data alongside it.
- public static string UserConfigDirectory() =>
- Path.GetDirectoryName(UserConfigPath()) ?? AppContext.BaseDirectory;
-
- ///
- /// The simulator executable to launch. With auto-selection on, the working
- /// directory is scanned for an eu07* binary - eu07 on Linux/macOS,
- /// eu07.exe on Windows - so the same procedure works on every OS. With it
- /// off, the explicitly chosen path is used.
- ///
- public string ResolveExecutable()
- {
- if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath))
- return ExecutablePath;
-
- string canonical = OperatingSystem.IsWindows() ? "eu07.exe" : "eu07";
- try
- {
- string? best = null;
- foreach (string path in Directory.GetFiles(Directory.GetCurrentDirectory(), "eu07*"))
- {
- if (!IsExecutableCandidate(path))
- continue;
- string file = Path.GetFileName(path);
- if (string.Equals(file, canonical, StringComparison.OrdinalIgnoreCase))
- return path; // exact canonical name wins
- if (best == null || file.Length < Path.GetFileName(best).Length)
- best = path; // otherwise prefer the shortest eu07* name
- }
- if (best != null)
- return best;
- }
- catch { /* fall through to the canonical name */ }
-
- return canonical;
- }
-
- ///
- /// Scans (defaults to the working directory) for
- /// plausible eu07* launcher binaries, newest first, for populating the
- /// manual-selection dropdown. Does not affect .
- ///
- public static List ListCandidateExecutables(string? directory = null)
- {
- directory ??= Directory.GetCurrentDirectory();
- try
- {
- return Directory.GetFiles(directory, "eu07*")
- .Where(IsExecutableCandidate)
- .OrderByDescending(File.GetLastWriteTimeUtc)
- .Select(Path.GetFileName)
- .ToList()!;
- }
- catch { return new List(); }
- }
-
- // Filters the eu07* matches down to plausible launcher binaries, skipping data
- // and config files (eu07.ini, eu07.log, libraries, …).
- private static bool IsExecutableCandidate(string path)
- {
- string ext = Path.GetExtension(path).ToLowerInvariant();
- if (ext is ".ini" or ".log" or ".txt" or ".cfg" or ".config" or ".json"
- or ".dll" or ".so" or ".dat" or ".bak" or ".csv" or ".xml")
- return false;
- if (OperatingSystem.IsWindows())
- return ext == ".exe";
- // Linux / macOS: no extension (eu07), a known binary suffix, or .exe
- // (some builds - notably this project's own build-macos.sh - ship the
- // macOS binary under the historical "eu07.exe" name).
- return ext.Length == 0 || ext is ".exe" or ".x86_64" or ".run" or ".appimage";
- }
-
- private static readonly Encoding FileEncoding = ResolveEncoding();
-
- private static Encoding ResolveEncoding()
- {
- // eu07.ini comments use Polish diacritics in Windows-1250; preserve them.
- try
- {
- Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
- return Encoding.GetEncoding(1250);
- }
- catch
- {
- return Encoding.UTF8;
- }
- }
-
- /// Loads settings from the per-user file, or the working-dir default.
- public void Load()
- {
- _savePath = UserConfigPath();
- string source = File.Exists(_savePath) ? _savePath : DefaultConfigPath();
- LoadFrom(source);
- }
-
- /// Captures the latest UI values (if the view is open) and saves.
- public void CaptureAndSave()
- {
- CaptureFromUi?.Invoke();
- Save();
- }
-
- /// Loads settings from an arbitrary path without changing where writes.
- public void LoadFrom(string path)
- {
- try
- {
- _config = File.Exists(path)
- ? ConfigFile.Parse(File.ReadAllText(path, FileEncoding))
- : new ConfigFile();
- }
- catch
- {
- _config = new ConfigFile();
- }
-
- ReadFromConfig();
- }
-
- /// Persists settings to the per-user eu07.ini, keeping unknown keys.
- public void Save()
- {
- if (string.IsNullOrEmpty(_savePath))
- _savePath = UserConfigPath();
-
- SaveTo(_savePath);
- }
-
- /// Writes settings to an arbitrary path without changing where writes.
- public void SaveTo(string path)
- {
- WriteToConfig();
-
- try
- {
- string? dir = Path.GetDirectoryName(path);
- if (!string.IsNullOrEmpty(dir))
- Directory.CreateDirectory(dir);
- File.WriteAllText(path, _config.ToText(), FileEncoding);
- }
- catch
- {
- // Could not write (permissions / path). Leave values in memory.
- }
- }
-
- // ── config → fields ───────────────────────────────────────────────────
- private void ReadFromConfig()
- {
- var c = _config;
-
- // General
- LanguageWasSet = c.Has("lang");
- Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English";
- Fullscreen = c.GetBool("fullscreen", false);
- PauseWhenInactive = c.GetBool("inactivepause", true);
- PauseOnStart = c.GetBool("pause", false);
-
- var mouse = c.GetTokens("mousescale");
- if (mouse.Length >= 1 && double.TryParse(mouse[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double mx))
- {
- InvertMouseHorizontal = mx < 0;
- CursorSensitivity = Clamp((int)Math.Round(Math.Abs(mx)), 1, 5);
- }
- if (mouse.Length >= 2 && double.TryParse(mouse[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double my))
- {
- InvertMouseVertical = my < 0;
- _mouseScaleYMagnitude = Math.Abs(my);
- }
-
- // Communication
- IgnoreGamepad = !c.GetBool("input.gamepad", true);
- FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5);
- FeedbackPort = c.GetInt("feedbackport", 888);
- UartEnabled = c.Has("uart");
- UartPort = c.GetString("uart", "");
- UartTune = c.GetString("uarttune", "");
- UartDebug = c.GetBool("uartdebug", false);
- if (c.Has("uartfeature"))
- {
- var uartTokens = (c.GetRaw("uartfeature") ?? "").Split('|', StringSplitOptions.RemoveEmptyEntries);
- var uf = new HashSet(uartTokens, StringComparer.OrdinalIgnoreCase);
- UartMain = uf.Contains("main"); UartScnd = uf.Contains("scnd"); UartTrain = uf.Contains("train");
- UartLocal = uf.Contains("local"); UartRadioVolume = uf.Contains("radiovolume"); UartRadioChannel = uf.Contains("radiochannel");
- }
- else
- {
- UartMain = UartScnd = UartTrain = UartLocal = true;
- UartRadioVolume = UartRadioChannel = false;
- }
-
- // Other
- SelectExeAutomatically = c.GetBool("starter.exe.auto", true);
- ExecutablePath = c.GetString("starter.exe.path", "eu07.exe");
- DebugMode = c.GetBool("debugmode", false);
- VirtualShunting = c.GetBool("ai.trainman", true);
- LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false);
-
- // Graphics
- RenderEngine = IndexOf(RenderEngines, c.GetString("gfxrenderer", "full"), 0);
- Width = c.GetInt("width", 1280);
- Height = c.GetInt("height", 720);
- BufferScalePercent = Clamp(c.GetInt("starter.bufferscale", 100), 50, 200);
- MaxTextureSize = c.GetInt("maxtexturesize", 4096);
- MaxCabTextureSize = c.GetInt("maxcabtexturesize", 4096);
- TextureFiltering = c.GetInt("anisotropicfiltering", 8);
- Multisampling = Clamp(c.GetInt("multisampling", 2), 0, 3);
- DynamicLights = Clamp(c.GetInt("dynamiclights", 7), 0, 7);
- DrawRangeFactor = c.GetDouble("gfx.drawrange.factor.max", 3.0);
- VSync = c.GetBool("vsync", false);
- Smoke = c.GetBool("gfx.smoke", true);
- SmokeFidelity = Clamp(c.GetInt("gfx.smoke.fidelity", 1), 1, 4);
- ChromaticAberration = c.GetBool("gfx.postfx.chromaticaberration.enabled", true);
- MotionBlur = c.GetBool("gfx.postfx.motionblur.enabled", true);
- ExtraEffects = c.GetBool("gfx.extraeffects", true);
- EnvMap = c.GetBool("gfx.envmap.enabled", true);
- UseVbo = c.GetBool("usevbo", true);
- RenderShadows = c.GetBool("shadows", true);
- ReflectionsFramerate = c.GetInt("gfx.reflections.framerate", 60);
-
- var shadowTune = c.GetTokens("shadowtune");
- if (shadowTune.Length >= 1) ShadowMapResolution = ParseInt(shadowTune[0], 2048);
- if (shadowTune.Length >= 2) ShadowProjectionRange = ParseInt(shadowTune[1], 250);
- if (shadowTune.Length >= 4) _shadowTuneExtra = new[] { shadowTune[2], shadowTune[3] };
-
- CabShadowsRange = (int)Math.Round(c.GetDouble("gfx.shadows.cab.range", 0));
- ShadowRankCutoff = c.GetInt("gfx.shadow.rank.cutoff", 3);
- ReflectionsFidelity = Clamp(c.GetInt("gfx.reflections.fidelity", 0), 0, 2);
- FieldOfView = Clamp((int)Math.Round(c.GetDouble("fieldofview", 45)), 15, 75);
- PythonScreens = c.GetBool("python.enabled", true);
- PythonThreadedUpload = c.GetBool("python.threadedupload", true);
- FpsLimitEnabled = c.Has("fpslimit");
- FpsLimit = Clamp(c.GetInt("fpslimit", 30), 1, 100);
- ShadowAngleLimit = c.GetDouble("gfx.shadow.angle.min", 0.2);
- FullscreenWindowed = c.GetBool("fullscreenwindowed", false);
- RenderAngleVulkan = string.Equals(c.GetString("gfx.angleplatform", ""), "vulkan", StringComparison.OrdinalIgnoreCase);
-
- // Physics
- SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4);
- FullPhysics = c.GetBool("fullphysics", true);
- EnableTraction = c.GetBool("enabletraction", true);
- LiveTraction = c.GetBool("livetraction", true);
- PhysicsLog = c.GetBool("physicslog", false);
- DebugLog = c.GetInt("debuglog", 3) != 0;
- MultipleLogs = c.GetBool("multiplelogs", false);
- DisplaySimulation = !c.GetBool("gfx.skiprendering", false);
- CrashDamage = c.GetBool("crashdamage", true);
- Friction = c.GetDouble("friction", 1.0);
- BrakeStep = c.GetDouble("brakestep", 1.0);
- BrakeSpeed = c.GetDouble("brakespeed", 3.0);
-
- // Sound
- SoundEnabled = c.GetBool("soundenabled", true);
- Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100);
- RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100);
- VehiclesVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.vehicle", 1.0) * 100), 1, 100);
- PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 1.0) * 100), 1, 100);
- AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 1, 100);
-
- // Advanced
- CompressTextures = c.GetBool("compresstex", true);
- ScaleSpeculars = c.GetBool("scalespeculars", true);
- UseGLES = c.GetBool("gfx.usegles", false);
- ShaderGamma = c.GetBool("gfx.shadergamma", false);
- ScreenMipmaps = c.GetBool("python.mipmaps", true);
- ExtendedModelConversion = c.GetInt("convertmodels", 0) != 0;
- GfxResourceMove = c.GetBool("gfx.resource.move", false);
- GfxResourceSweep = c.GetBool("gfx.resource.sweep", true);
- TrainPhysicsThreads = Clamp(c.GetInt("async.trainThreads", 0), 0, Environment.ProcessorCount);
- IgnoreIrrelevantTrains = c.GetBool("starter.ignoreirrelevant", false);
-
- // Starter
- AutoCloseStarter = c.GetBool("starter.autoclose", false);
- LargeThumbnails = c.GetBool("starter.largethumbnails", false);
- AutoExpandSceneryTree = c.GetBool("starter.expandtree", false);
- BatteryDefault = (BatteryDefault)Clamp(c.GetInt("starter.batterydefault", 0), 0, 2);
- ShowAiVehicles = c.GetBool("starter.show.ai", true);
- DrivableOnly = c.GetBool("starter.drivableonly", true);
- ShowArchivalSceneries = c.GetBool("starter.show.archival", true);
- }
-
- // ── fields → config ───────────────────────────────────────────────────
- private void WriteToConfig()
- {
- var c = _config;
-
- // General
- c.Set("lang", Language == "Polski" ? "pl" : "en");
- c.SetBool("fullscreen", Fullscreen);
- c.SetBool("inactivepause", PauseWhenInactive);
- c.SetBool("pause", PauseOnStart);
-
- double mx = CursorSensitivity * (InvertMouseHorizontal ? -1 : 1);
- double my = _mouseScaleYMagnitude * (InvertMouseVertical ? -1 : 1);
- c.Set("mousescale",
- $"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " +
- $"{my.ToString("0.###", CultureInfo.InvariantCulture)}");
-
- // Communication
- c.SetBool("input.gamepad", !IgnoreGamepad);
- c.SetInt("feedbackmode", FeedbackMode);
- c.SetInt("feedbackport", FeedbackPort);
- if (UartEnabled && !string.IsNullOrWhiteSpace(UartPort)) c.Set("uart", UartPort); else c.Remove("uart");
- c.Set("uarttune", UartTune);
- c.SetBool("uartdebug", UartDebug);
- var sb = new StringBuilder();
- if (UartMain) sb.Append("main|");
- if (UartScnd) sb.Append("scnd|");
- if (UartTrain) sb.Append("train|");
- if (UartLocal) sb.Append("local|");
- if (UartRadioVolume) sb.Append("radiovolume|");
- if (UartRadioChannel) sb.Append("radiochannel|");
- c.Set("uartfeature", sb.ToString());
-
- // Other
- c.SetBool("starter.exe.auto", SelectExeAutomatically);
- c.Set("starter.exe.path", ExecutablePath);
- c.SetBool("debugmode", DebugMode);
- c.SetBool("ai.trainman", VirtualShunting);
- c.SetBool("starter.logmissingvehicles", LogMissingVehicleFiles);
-
- // Graphics
- c.Set("gfxrenderer", RenderEngines[Clamp(RenderEngine, 0, RenderEngines.Length - 1)]);
- c.SetInt("width", Width);
- c.SetInt("height", Height);
- c.SetInt("starter.bufferscale", BufferScalePercent);
- c.SetInt("maxtexturesize", MaxTextureSize);
- c.SetInt("maxcabtexturesize", MaxCabTextureSize);
- c.SetInt("anisotropicfiltering", TextureFiltering);
- c.SetInt("multisampling", Multisampling);
- c.SetInt("dynamiclights", DynamicLights);
- c.SetDouble("gfx.drawrange.factor.max", DrawRangeFactor);
- c.SetBool("vsync", VSync);
- c.SetBool("gfx.smoke", Smoke);
- c.SetInt("gfx.smoke.fidelity", SmokeFidelity);
- c.SetBool("gfx.postfx.chromaticaberration.enabled", ChromaticAberration);
- c.SetBool("gfx.postfx.motionblur.enabled", MotionBlur);
- c.SetBool("gfx.extraeffects", ExtraEffects);
- c.SetBool("gfx.envmap.enabled", EnvMap);
- c.SetBool("usevbo", UseVbo);
- c.SetBool("shadows", RenderShadows);
- c.SetBool("gfx.shadowmap.enabled", RenderShadows);
- c.SetInt("gfx.reflections.framerate", ReflectionsFramerate);
- c.Set("shadowtune",
- $"{ShadowMapResolution} {ShadowProjectionRange} " +
- $"{_shadowTuneExtra[0]} {_shadowTuneExtra[1]}");
- c.SetInt("gfx.shadows.cab.range", CabShadowsRange);
- c.SetInt("gfx.shadow.rank.cutoff", ShadowRankCutoff);
- c.SetInt("gfx.reflections.fidelity", ReflectionsFidelity);
- c.SetInt("fieldofview", FieldOfView);
- c.SetBool("python.enabled", PythonScreens);
- c.SetBool("python.threadedupload", PythonThreadedUpload);
- if (FpsLimitEnabled) c.SetInt("fpslimit", FpsLimit); else c.Remove("fpslimit");
- c.SetDouble("gfx.shadow.angle.min", ShadowAngleLimit);
- c.SetBool("fullscreenwindowed", FullscreenWindowed);
- if (RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); else c.Remove("gfx.angleplatform");
-
- // Physics
- c.SetInt("splinefidelity", SplineFidelity);
- c.SetBool("fullphysics", FullPhysics);
- c.SetBool("enabletraction", EnableTraction);
- c.SetBool("livetraction", LiveTraction);
- c.SetBool("physicslog", PhysicsLog);
- c.SetInt("debuglog", DebugLog ? 3 : 0);
- c.SetBool("multiplelogs", MultipleLogs);
- c.SetBool("gfx.skiprendering", !DisplaySimulation);
- c.SetBool("crashdamage", CrashDamage);
- c.SetDouble("friction", Friction);
- c.SetDouble("brakestep", BrakeStep);
- c.SetDouble("brakespeed", BrakeSpeed);
-
- // Sound
- c.SetBool("soundenabled", SoundEnabled);
- c.SetDouble("sound.volume", Volume / 50.0);
- c.SetDouble("sound.volume.radio", RadioVolume / 100.0);
- c.SetDouble("sound.volume.vehicle", VehiclesVolume / 100.0);
- c.SetDouble("sound.volume.positional", PositionalVolume / 100.0);
- c.SetDouble("sound.volume.ambient", AmbientVolume / 100.0);
-
- // Advanced
- c.SetBool("compresstex", CompressTextures);
- c.SetBool("scalespeculars", ScaleSpeculars);
- c.SetBool("gfx.usegles", UseGLES);
- c.SetBool("gfx.shadergamma", ShaderGamma);
- c.SetBool("python.mipmaps", ScreenMipmaps);
- c.Set("convertmodels", ExtendedModelConversion ? "143" : "0");
- c.SetBool("gfx.resource.move", GfxResourceMove);
- c.SetBool("gfx.resource.sweep", GfxResourceSweep);
- c.SetInt("async.trainThreads", TrainPhysicsThreads);
- c.SetBool("starter.ignoreirrelevant", IgnoreIrrelevantTrains);
-
- // Starter
- c.SetBool("starter.autoclose", AutoCloseStarter);
- c.SetBool("starter.largethumbnails", LargeThumbnails);
- c.SetBool("starter.expandtree", AutoExpandSceneryTree);
- c.SetInt("starter.batterydefault", (int)BatteryDefault);
- c.SetBool("starter.show.ai", ShowAiVehicles);
- c.SetBool("starter.drivableonly", DrivableOnly);
- c.SetBool("starter.show.archival", ShowArchivalSceneries);
- }
-
- // ── small helpers ──────────────────────────────────────────────────────
- private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v);
-
- private static int ParseInt(string s, int fallback) =>
- int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? n : fallback;
-
- private static int IndexOf(string[] values, string token, int fallback)
- {
- for (int i = 0; i < values.Length; i++)
- if (string.Equals(values[i], token, StringComparison.OrdinalIgnoreCase))
- return i;
- return fallback;
- }
-}
diff --git a/StarterNG/Classes/SettingsProfileStore.cs b/StarterNG/Classes/SettingsProfileStore.cs
index 78858f2..1ea7ec5 100644
--- a/StarterNG/Classes/SettingsProfileStore.cs
+++ b/StarterNG/Classes/SettingsProfileStore.cs
@@ -2,13 +2,14 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using StarterNG.Application;
namespace StarterNG.Classes;
public static class SettingsProfileStore
{
public static string DirectoryPath { get; } =
- Path.Combine(Settings.UserConfigDirectory(), "starter", "profiles");
+ Path.Combine(AppServices.Current.SettingsPaths.UserConfigDirectory(), "starter", "profiles");
public static IReadOnlyList ListProfiles()
{
diff --git a/StarterNG/Classes/Trainset.cs b/StarterNG/Classes/Trainset.cs
index f857168..47ed387 100644
--- a/StarterNG/Classes/Trainset.cs
+++ b/StarterNG/Classes/Trainset.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
@@ -10,7 +10,7 @@ namespace StarterNG.Classes;
public enum eDriverType
{
Headdriver,
- Reardriver,
+ Reardriver,
Passenger,
Nobody
}
@@ -22,18 +22,42 @@ public class Trainset
public float Offset;
public float Velocity;
public float OriginalVelocity;
+
+ // The scenery format has no battery field: a trainset with a non-zero speed is
+ // the prepared one. DynObj.cpp derives ReadyFlag from it, and that branch of
+ // CheckLocomotiveParameters is what charges the pipes, sets the brake handle
+ // and closes the battery contactor. The sign carries the direction, so a
+ // consist authored on the move keeps its own speed.
+ private const float StandingStill = 0.01f;
+ private const float PreparedCreep = 0.1f;
+
+ public bool ReadyToGo
+ {
+ get => MathF.Abs(Velocity) >= StandingStill;
+ set
+ {
+ if (!value)
+ {
+ Velocity = 0f;
+ return;
+ }
+
+ Velocity = MathF.Abs(OriginalVelocity) < StandingStill ? PreparedCreep : OriginalVelocity;
+ }
+ }
public string Description;
public List Vehicles;
- /// Original .scn text of this trainset block (incl. endtrainset).
public string RawEntry;
- /// True if the block parsed cleanly; false blocks are exported verbatim.
public bool Parsed;
- /// True when the block carries the //$decor flag (decoration, not drivable).
public bool Decor;
+ public string? Logo;
+
+ public string? Mini;
+
public Trainset(string trainsetEntry)
{
RawEntry = trainsetEntry;
@@ -59,8 +83,6 @@ public class Trainset
.Where(t => !t.StartsWith("//") && !t.StartsWith("/*"))
.ToList();
- // handle specific descriptions
- // //$o
var match = Regex.Match(
trainsetEntry,
@"^\s*//\$o\s*(.*)$",
@@ -68,15 +90,20 @@ public class Trainset
);
if (match.Success)
this.Description = match.Groups[1].Value.Trim();
-
- // parse trainset
-
+
+ var logo = Regex.Match(trainsetEntry, @"^\s*//\$il\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
+ if (logo.Success)
+ Logo = logo.Groups[1].Value.Trim();
+
+ var mini = Regex.Match(trainsetEntry, @"^\s*//\$it\b[ \t]*([^\r\n]*)", RegexOptions.Multiline);
+ if (mini.Success)
+ Mini = mini.Groups[1].Value.Trim();
+
for (int i = 0; i < tokens.Count; i++)
{
if (tokens[i] == "endtrainset")
break;
-
- // trainset properties
+
if (tokens[i] == "trainset")
{
this.Name = tokens[++i];
@@ -86,26 +113,24 @@ public class Trainset
this.OriginalVelocity = this.Velocity;
continue;
}
-
- // skip entire assignments block
+
if (tokens[i] == "assignments")
{
while (i < tokens.Count && tokens[i] != "endassignment")
i++;
- i++; // jump over endassignment
+ i++;
continue;
}
-
- // load vehicles
+
if (tokens[i] == "node")
{
Dynamic nodeDynamic = new Dynamic();
nodeDynamic.RangeMax = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.RangeMin = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
nodeDynamic.Name = tokens[++i];
- i++; // dynamic keyword
+ i++;
nodeDynamic.DataFolder = tokens[++i];
- nodeDynamic.SkinFile = tokens[++i];
+ nodeDynamic.SkinFile = Dynamic.StripSkinExtension(tokens[++i]);
nodeDynamic.MmdFile = tokens[++i];
nodeDynamic.Offset = float.Parse(tokens[++i], CultureInfo.InvariantCulture);
@@ -117,69 +142,47 @@ public class Trainset
_ => eDriverType.Nobody
};
- // couplingdata: numeric bit-mask plus optional ".B/.W/.T" parameter
- // codes (brake / wheel / thermo settings) kept verbatim.
nodeDynamic.Coupling = Coupling.Parse(tokens[++i]);
- // Everything before enddynamic is the optional, positional tail
- // documented for node::dynamic:
- // [Lx] velocity [loadcount [loadtype]] [destination [destination]]
var trailing = new List();
while (i + 1 < tokens.Count && tokens[i + 1] != "enddynamic")
trailing.Add(tokens[++i]);
nodeDynamic.ReadTrailing(trailing);
- i++; // jump over enddynamic
+ i++;
Vehicles.Add(nodeDynamic);
}
}
}
-
- ///
- /// The .scn text for this trainset for export. Unparsed blocks are written
- /// back verbatim; parsed blocks are regenerated from the current vehicles.
- ///
+
public string ToSceneryEntry() =>
Parsed ? GetTrainsetEntry() + "endtrainset\n" : RawEntry;
public string GetTrainsetEntry()
{
- string entry = "";
- entry += "trainset ";
- entry += this.Name + " ";
- entry += this.Track + " ";
- entry += this.Offset.ToString(CultureInfo.InvariantCulture) + " ";
- entry += this.Velocity.ToString(CultureInfo.InvariantCulture) + " ";
- entry += "\n";
+ var sb = new StringBuilder();
+ if (Decor)
+ sb.Append("//$decor\n");
+ if (!string.IsNullOrEmpty(Description))
+ sb.Append("//$o ").Append(Description).Append('\n');
+ if (!string.IsNullOrEmpty(Logo))
+ sb.Append("//$il ").Append(Logo).Append('\n');
+ if (!string.IsNullOrEmpty(Mini))
+ sb.Append("//$it ").Append(Mini).Append('\n');
+
+ sb.Append("trainset ");
+ sb.Append(Name).Append(' ');
+ sb.Append(Track).Append(' ');
+ sb.Append(Offset.ToString(CultureInfo.InvariantCulture)).Append(' ');
+ sb.Append(Velocity.ToString(CultureInfo.InvariantCulture));
+ sb.Append('\n');
+
+ string entry = sb.ToString();
foreach (Dynamic vehicle in Vehicles)
- {
- string driverType = "";
- switch (vehicle.DriverType)
- {
- case eDriverType.Headdriver:
- driverType = "headdriver";
- break;
- case eDriverType.Reardriver:
- driverType = "reardriver";
- break;
- case eDriverType.Passenger:
- driverType = "passenger";
- break;
- default:
- driverType = "nobody";
- break;
- }
+ entry += vehicle.ToTrainsetNode();
- entry +=
- $"node {vehicle.RangeMax} {vehicle.RangeMin} {vehicle.Name} dynamic " +
- $"{vehicle.DataFolder} {vehicle.SkinFile} {vehicle.MmdFile} " +
- $"{vehicle.Offset.ToString(CultureInfo.InvariantCulture)} " +
- $"{driverType} {vehicle.Coupling}{vehicle.WriteTrailing()} enddynamic\n";
- }
-
-
return entry;
}
}
@@ -196,112 +199,92 @@ public class Dynamic
public float Offset;
public eDriverType DriverType;
- ///
- /// Coupling with the next vehicle (the couplingdata field): the bit-mask
- /// plus any ".B/.W/.T" parameter codes (brake / wheel / thermo settings).
- ///
public Coupling Coupling = new();
- ///
- /// Low byte of the coupling mask, exposed for the depot's per-bit editor.
- /// Backed by so the two never diverge.
- ///
public byte couplingData
{
get => (byte)(Coupling.AbsFlags & 0xFF);
set => Coupling.Flags = value;
}
- // --- optional node::dynamic trailing parameters (all positional, all optional) ---
+ public const string PantState = "pantstate";
- /// "Lx" MaxLoad override token (e.g. "L0"), or null when absent.
- public string? MaxLoad;
-
- /// Whether a per-vehicle velocity token is present / should be written.
- public bool HasVelocity;
-
- /// Per-vehicle starting velocity.
- public float Velocity;
-
- /// Cargo amount (loadcount).
- public int LoadCount;
-
- /// Cargo type (loadtype) — only meaningful when > 0.
- public string? LoadType;
-
- /// Optional cargo destination tokens (rarely used; preserved verbatim).
- public List Destinations = new();
-
- // --- depot / consist-builder extras (not part of the .scn syntax) ---
-
- /// Explicit miniature name (from the vehicle database). Falls back to SkinFile.
- public string? MiniName;
-
- /// Whether the vehicle is visually reversed in the consist.
- public bool Flipped;
+ public const int PantStateMax = 3;
///
- /// Parses the optional positional parameters that follow couplingdata:
- /// [Lx] velocity [loadcount [loadtype]] [destination [destination]].
- /// See https://wiki.eu07.pl/index.php?title=Obiekt_node::dynamic
+ /// Lives in the coupling token as ".L<n>" - that is where the simulator reads
+ /// it from (DynObj.cpp, the MoreParams loop). The older space-separated "L<n>"
+ /// token in front of the load count is still read for legacy files, but never
+ /// written back: the current scenario parser reads that slot as the load count.
///
+ public int MaxLoad
+ {
+ get => Coupling.MaxLoad;
+ set => Coupling.MaxLoad = value;
+ }
+
+ public bool HasVelocity;
+
+ public float Velocity;
+
+ public int LoadCount;
+
+ public string? LoadType;
+
+ public string? MiniName;
+
+ public bool Flipped;
+
+ public static bool IsPantStateType(string? type) =>
+ string.Equals(type, PantState, StringComparison.OrdinalIgnoreCase);
+
+ public bool IsPantState => IsPantStateType(LoadType);
+
internal void ReadTrailing(List t)
{
int p = 0;
- // Lx — MaxLoad override (e.g. "L0"); only when it genuinely looks like one.
- if (p < t.Count && (t[p][0] == 'L' || t[p][0] == 'l')
- && t[p].Length > 1 && char.IsDigit(t[p][1]))
- MaxLoad = t[p++];
-
- // velocity — the first plain number after the coupling
- if (p < t.Count && float.TryParse(t[p], NumberStyles.Float,
- CultureInfo.InvariantCulture, out float v))
+ // Legacy form: max load as its own token in front of the load count. The
+ // current scenario parser reads that slot as an int, so this is read only -
+ // the setter moves the value into the coupling token, where it is written.
+ if (p < t.Count && t[p].Length > 0 && t[p][0] == 'L')
{
- Velocity = v;
- HasVelocity = true;
+ if (int.TryParse(t[p].AsSpan(1), NumberStyles.Integer,
+ CultureInfo.InvariantCulture, out int ml))
+ MaxLoad = ml;
p++;
}
- // loadcount [loadtype] — loadtype is present only when loadcount > 0
if (p < t.Count && int.TryParse(t[p], NumberStyles.Integer,
CultureInfo.InvariantCulture, out int lc))
{
LoadCount = lc;
p++;
- if (lc > 0 && p < t.Count)
+ if (p < t.Count && (lc > 0 || IsPantStateType(t[p])))
LoadType = t[p++];
}
- // remaining tokens: optional destinations, kept verbatim
- while (p < t.Count)
- Destinations.Add(t[p++]);
}
- ///
- /// Regenerates the trailing parameter string (each present token prefixed with
- /// a space), the inverse of . A loadcount of 0 is
- /// omitted, matching the canonical trainset entries the simulator expects.
- ///
+ internal static string StripSkinExtension(string token)
+ {
+ int dot = token.LastIndexOf('.');
+ return dot > 0 ? token[..dot] : token;
+ }
+
internal string WriteTrailing()
{
var sb = new StringBuilder();
- if (MaxLoad != null)
- sb.Append(' ').Append(MaxLoad);
- if (HasVelocity)
- sb.Append(' ').Append(Velocity.ToString(CultureInfo.InvariantCulture));
- if (LoadCount > 0)
- {
- sb.Append(' ').Append(LoadCount.ToString(CultureInfo.InvariantCulture));
- if (!string.IsNullOrEmpty(LoadType))
- sb.Append(' ').Append(LoadType);
- }
- foreach (string d in Destinations)
- sb.Append(' ').Append(d);
+
+ bool loaded = !string.IsNullOrEmpty(LoadType);
+ sb.Append(' ').Append((loaded ? LoadCount : 0).ToString(CultureInfo.InvariantCulture));
+
+ if (loaded && (LoadCount > 0 || IsPantState))
+ sb.Append(' ').Append(LoadType);
+
return sb.ToString();
}
- /// Deep copy, used when importing a trainset into the editable consist.
public Dynamic Clone() => new Dynamic
{
RangeMax = RangeMax,
@@ -314,50 +297,77 @@ public class Dynamic
Offset = Offset,
DriverType = DriverType,
Coupling = Coupling.Clone(),
- MaxLoad = MaxLoad,
HasVelocity = HasVelocity,
Velocity = Velocity,
LoadCount = LoadCount,
LoadType = LoadType,
- Destinations = new List(Destinations),
MiniName = MiniName,
Flipped = Flipped
};
+
+ public string ToTrainsetNode()
+ {
+ string driver = DriverType switch
+ {
+ eDriverType.Headdriver => "headdriver",
+ eDriverType.Reardriver => "reardriver",
+ eDriverType.Passenger => "passenger",
+ _ => "nobody"
+ };
+
+ return
+ $"node {RangeMax} {RangeMin} {Name} dynamic " +
+ $"{DataFolder} {SkinFile} {MmdFile} " +
+ $"{Offset.ToString(CultureInfo.InvariantCulture)} " +
+ $"{driver} {Coupling}{WriteTrailing()} enddynamic\n";
+ }
+
+ public string ToLooseNode()
+ {
+ string driver = DriverType switch
+ {
+ eDriverType.Headdriver => "headdriver",
+ eDriverType.Reardriver => "reardriver",
+ eDriverType.Passenger => "passenger",
+ _ => "nobody"
+ };
+ string path = string.IsNullOrEmpty(PathName) ? "none" : PathName;
+ string vel = (HasVelocity ? Velocity : 0f).ToString(CultureInfo.InvariantCulture);
+ return
+ $"node {RangeMax.ToString(CultureInfo.InvariantCulture)} " +
+ $"{RangeMin.ToString(CultureInfo.InvariantCulture)} {Name} dynamic " +
+ $"{DataFolder} {SkinFile} {MmdFile} {path} " +
+ $"{Offset.ToString(CultureInfo.InvariantCulture)} {driver} {vel}" +
+ $"{WriteTrailing()} enddynamic\n";
+ }
+}
+
+public sealed class ConsistItem
+{
+ public List Cars { get; set; } = new();
+ public bool Grouped { get; set; }
+ public bool Flipped { get; set; }
+ public eDriverType Driver { get; set; } = eDriverType.Nobody;
}
-///
-/// The couplingdata value joining a vehicle to the next one: a bit-mask of
-/// the active coupling types, optionally followed by ".B/.W/.T" parameter codes.
-/// A negative mask means the coupling is locked (cannot be uncoupled in-sim); the
-/// simulator takes its absolute value before testing the individual bits.
-/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
-///
public sealed class Coupling
{
- // Mask bit values, per the wiki.
- public const int Mechanical = 1; // mechanical link
- public const int BrakePipe = 2; // pneumatic 5 atm (brakes)
- public const int ControlMU = 4; // multiple-unit control
- public const int HighVoltage = 8; // high voltage
- public const int Gangway = 16; // passage between vehicles
- public const int AuxPneumatic = 32; // auxiliary pneumatic 8 atm
- public const int Heating = 64; // heating
- public const int WorkshopLock = 128; // workshop coupling (uncouple lock)
- /// Signed mask exactly as written; negative = locked.
+ public const int Mechanical = 1;
+ public const int BrakePipe = 2;
+ public const int ControlMU = 4;
+ public const int HighVoltage = 8;
+ public const int Gangway = 16;
+ public const int AuxPneumatic = 32;
+ public const int Heating = 64;
+ public const int WorkshopLock = 128;
+
public int Flags;
- ///
- /// Textual parameter codes that follow the mask after a '.', in order
- /// (e.g. "BR", "WH25F5"). Preserved verbatim so brake / wheel / thermo
- /// settings survive a round-trip even when the launcher does not edit them.
- ///
public List Parameters = new();
- /// Mask with the lock sign removed (what the simulator tests bits on).
public int AbsFlags => Flags < 0 ? -Flags : Flags;
- /// True when the coupling is marked permanent (negative mask).
public bool Locked
{
get => Flags < 0;
@@ -366,7 +376,6 @@ public sealed class Coupling
public bool Has(int bit) => (AbsFlags & bit) != 0;
- /// Sets or clears a mask bit, preserving the lock sign.
public void Set(int bit, bool on)
{
int abs = on ? (AbsFlags | bit) : (AbsFlags & ~bit);
@@ -388,25 +397,21 @@ public sealed class Coupling
return c;
}
- /// The brake-rack setting carried in the "B" parameter, or null.
public BrakeSetting? GetBrake() =>
BrakeSetting.FromParameter(
Parameters.FirstOrDefault(p => p.StartsWith("B", StringComparison.Ordinal)));
- /// Replaces (or clears, when null) the "B" brake parameter.
public void SetBrake(BrakeSetting? brake)
{
Parameters.RemoveAll(p => p.StartsWith("B", StringComparison.Ordinal));
- if (brake != null)
+ if (brake is { IsEmpty: false })
Parameters.Insert(0, brake.ToParameter());
}
- /// The wheel/damage setting carried in the "W" parameter, or null.
public WheelSettings? GetWheels() =>
WheelSettings.FromParameter(
Parameters.FirstOrDefault(p => p.StartsWith("W", StringComparison.Ordinal)));
- /// Replaces (or clears, when empty/null) the "W" wheel parameter.
public void SetWheels(WheelSettings? wheels)
{
Parameters.RemoveAll(p => p.StartsWith("W", StringComparison.Ordinal));
@@ -414,13 +419,44 @@ public sealed class Coupling
Parameters.Add(wheels.ToParameter());
}
+ ///
+ /// Max load override, the ".L<n>" sub-parameter. -1 means "not set", so the
+ /// vehicle keeps the MaxLoad from its .fiz.
+ ///
+ public int MaxLoad
+ {
+ get => Parameters.Find(IsMaxLoad) is { } p
+ && int.TryParse(p.AsSpan(1), NumberStyles.Integer,
+ CultureInfo.InvariantCulture, out int v) ? v : -1;
+ set
+ {
+ Parameters.RemoveAll(IsMaxLoad);
+ if (value >= 0)
+ Parameters.Add("L" + value.ToString(CultureInfo.InvariantCulture));
+ }
+ }
+
+ private static bool IsMaxLoad(string param) =>
+ param.Length > 1
+ && char.ToUpperInvariant(param[0]) == 'L'
+ && param.Skip(1).All(char.IsDigit);
+
+ public bool ThermoDynamic
+ {
+ get => Parameters.Exists(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase));
+ set
+ {
+ Parameters.RemoveAll(p => p.Equals("TA", StringComparison.OrdinalIgnoreCase));
+ if (value) Parameters.Add("TA");
+ }
+ }
+
public Coupling Clone() => new()
{
Flags = Flags,
Parameters = new List(Parameters)
};
- /// Serialises back to "<flags>[.param[.param…]]".
public override string ToString()
{
var sb = new StringBuilder();
@@ -431,67 +467,72 @@ public sealed class Coupling
}
}
-///
-/// A vehicle's brake-rack setting, carried in the "B" parameter of its coupling
-/// field: B<mode>[<load>][<switch>] (e.g. "BP", "BRA", "BG1").
-/// See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
-///
public sealed class BrakeSetting
{
- /// Brake modes, longest first so "R+Mg" wins over "R" while parsing.
- public static readonly string[] Modes = { "R+Mg", "G", "P", "R", "Q", "O", "A" };
+ public static readonly string[] Modes = { "G", "P", "R", "M" };
- /// Brake mode: G (freight), P (passenger), R (express), R+Mg, Q, O (off), A (auto).
- public string Mode = "P";
+ public static readonly string[] Loads = { "T", "H", "F" };
+
+ // The simulator tests every letter of the B parameter independently, so the
+ // "off" states are a separate axis from the G/P/R/M delay position. They stay
+ // one combo because they contradict each other, but they no longer block a
+ // delay position from being picked alongside them (e.g. "BRQ").
+ public static readonly string[] Switches = { "<>", "0", "1", "X", "E", "Q" };
+
+ public string? Mode;
- /// Load adaptation: T (empty), H (medium), F (loaded), A (auto), or null.
public string? Load;
- /// Brake on/off switch: 0 (off), 1 (off ~10%), A (on), or null.
public string? Switch;
+ // A brake switch or a load adaptation with no delay position is still a
+ // parameter worth writing, so all three axes count towards emptiness.
+ public bool IsEmpty => Mode is null && Load is null && Switch is null;
+
public static BrakeSetting? FromParameter(string? param)
{
- if (string.IsNullOrEmpty(param) || param![0] != 'B' || param.Length < 2)
+ if (string.IsNullOrEmpty(param) || char.ToUpperInvariant(param![0]) != 'B')
return null;
- string body = param.Substring(1);
- string? mode = Modes.FirstOrDefault(m => body.StartsWith(m, StringComparison.Ordinal));
- if (mode is null)
- return null;
+ string body = param.Substring(1).ToUpperInvariant();
+ var b = new BrakeSetting
+ {
+ // "M" first: the simulator reads it as R plus the magnetic rail brake,
+ // so a bare "R" must not swallow it. Switches are matched in the
+ // declared order too, so "<>" wins over the digits it cannot contain.
+ Mode = First(body, "M", "G", "P", "R"),
+ Load = First(body, Loads),
+ Switch = First(body, Switches)
+ };
+ return b.Mode is null && b.Load is null && b.Switch is null ? null : b;
- var b = new BrakeSetting { Mode = mode };
- int p = mode.Length;
- if (p < body.Length && "THFA".IndexOf(body[p]) >= 0)
- b.Load = body[p++].ToString();
- if (p < body.Length && "01A".IndexOf(body[p]) >= 0)
- b.Switch = body[p].ToString();
- return b;
+ static string? First(string body, params string[] codes) =>
+ Array.Find(codes, c => body.Contains(c, StringComparison.Ordinal));
}
- public string ToParameter() => "B" + Mode + (Load ?? "") + (Switch ?? "");
+ public string ToParameter() => "B" + Mode + Switch + Load;
}
-///
-/// Wheel-damage settings carried in the "W" parameter of a vehicle's coupling
-/// field: W[H<sway%>][F<flat mm>][R<random flat mm>][P<flat prob%>]
-/// (e.g. "WH25F5R10P8"). See https://wiki.eu07.pl/index.php?title=Wpisy_hamulca_dla_pojazdow
-///
public sealed class WheelSettings
{
- /// H — sway ("wężykowanie") probability, %.
public int Sway;
- /// F — flat spot ("podkucie") of this size, mm.
public int Flatness;
- /// R — additional random flat spot, 0..x mm.
public int FlatnessRand;
- /// P — flat-spot probability, % (guaranteed when omitted).
- public int FlatnessProb;
+ ///
+ /// Chance (%) that the flat spot is applied at all. The simulator defaults it
+ /// to 100 when the P sub-parameter is missing, so that is the default here too
+ /// - otherwise "0" would be written as "absent" and mean the exact opposite.
+ ///
+ public int FlatnessProb = DefaultFlatnessProb;
- public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0 && FlatnessProb <= 0;
+ public const int DefaultFlatnessProb = 100;
+
+ // A probability on its own does nothing: without a flat size there is nothing
+ // to roll for.
+ public bool IsEmpty => Sway <= 0 && Flatness <= 0 && FlatnessRand <= 0;
public static WheelSettings? FromParameter(string? param)
{
@@ -524,7 +565,7 @@ public sealed class WheelSettings
if (Sway > 0) sb.Append('H').Append(Sway);
if (Flatness > 0) sb.Append('F').Append(Flatness);
if (FlatnessRand > 0) sb.Append('R').Append(FlatnessRand);
- if (FlatnessProb > 0) sb.Append('P').Append(FlatnessProb);
+ if (FlatnessProb != DefaultFlatnessProb) sb.Append('P').Append(FlatnessProb);
return sb.ToString();
}
-}
\ No newline at end of file
+}
diff --git a/StarterNG/Classes/VehicleDatabase.cs b/StarterNG/Classes/VehicleDatabase.cs
deleted file mode 100644
index e9dd68b..0000000
--- a/StarterNG/Classes/VehicleDatabase.cs
+++ /dev/null
@@ -1,397 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using System.Text.Json.Serialization.Metadata;
-
-namespace StarterNG.Classes;
-
-// =====================================================================
-// Vehicle JSON database (databases/vehicles/*.json)
-// Format documented in vehicleEntryDoc.md (schema_version 1).
-// =====================================================================
-
-/// Root object of a single vehicle JSON file.
-public class VehicleEntry
-{
- [JsonPropertyName("uuid")] public string? Uuid { get; set; }
- [JsonPropertyName("schema_version")] public int SchemaVersion { get; set; }
- [JsonPropertyName("groups")] public List Groups { get; set; } = new();
- [JsonPropertyName("textures")] public List Textures { get; set; } = new();
- [JsonPropertyName("sets")] public List Sets { get; set; } = new();
- [JsonPropertyName("unknown")] public List Unknown { get; set; } = new();
-}
-
-/// Merged database root (databases/vehicles/vehicles.json).
-public class VehicleEntryCollection
-{
- [JsonPropertyName("vehicles")] public List Vehicles { get; set; } = new();
-}
-
-/// Legacy header / vehicle category group.
-public class VehicleGroup
-{
- [JsonPropertyName("id")] public string Id { get; set; } = "";
- [JsonPropertyName("category")] public string? Category { get; set; }
- [JsonPropertyName("mini")] public string? Mini { get; set; }
- [JsonPropertyName("archived")] public bool Archived { get; set; }
- [JsonPropertyName("implicit")] public bool Implicit { get; set; }
-}
-
-/// A single .mat skin entry.
-public class VehicleTexture
-{
- [JsonPropertyName("uuid")] public string? Uuid { get; set; }
- [JsonPropertyName("directory")] public string Directory { get; set; } = "";
- [JsonPropertyName("skinfile")] public string Skinfile { get; set; } = "";
- [JsonPropertyName("model")] public string? Model { get; set; }
- [JsonPropertyName("group")] public string? Group { get; set; }
- [JsonPropertyName("mini_ref")] public string? MiniRef { get; set; }
- [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
- [JsonPropertyName("wreck")] public bool Wreck { get; set; }
- [JsonPropertyName("aliases")] public List Aliases { get; set; } = new();
- [JsonPropertyName("meta")] public VehicleMeta? Meta { get; set; }
-
- /// Full skin path = directory + skinfile.
- [JsonIgnore] public string FullPath => Directory + Skinfile;
-
- // ── cached lookups, populated once after load (VehicleDatabase.BuildTextureIndex) ──
- // These mirror the depot's ClassOf / CategoryOf so the search/filter hot path
- // never recomputes them (no per-keystroke dictionary lookups over every texture).
-
- /// Group's "mini" (or this texture's mini_ref) — the vehicle class. Never null.
- [JsonIgnore] public string ResolvedClass { get; internal set; } = "";
-
- /// Group's "category" letter, or null when the group is unknown.
- [JsonIgnore] public string? ResolvedCategory { get; internal set; }
-}
-
-/// Alternate mapping from a malformed multi-= legacy line.
-public class VehicleAlias
-{
- [JsonPropertyName("model")] public string? Model { get; set; }
- [JsonPropertyName("group")] public string? Group { get; set; }
- [JsonPropertyName("mini_ref")] public string? MiniRef { get; set; }
- [JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
-}
-
-/// Parsed metadata from the legacy // comment section.
-public class VehicleMeta
-{
- [JsonPropertyName("raw")] public string? Raw { get; set; }
- [JsonPropertyName("version")] public string? Version { get; set; }
- [JsonPropertyName("vehicle")] public string? Vehicle { get; set; }
- [JsonPropertyName("operator")] public string? Operator { get; set; }
- [JsonPropertyName("depot")] public string? Depot { get; set; }
- [JsonPropertyName("revision_date")] public string? RevisionDate { get; set; }
- [JsonPropertyName("revision_place")] public string? RevisionPlace { get; set; }
- [JsonPropertyName("texture_author")] public string? TextureAuthor { get; set; }
- [JsonPropertyName("photo_author")] public string? PhotoAuthor { get; set; }
- [JsonPropertyName("extra")] public List Extra { get; set; } = new();
-}
-
-///
-/// Aggregated, in-memory view over every vehicle JSON file found in
-/// databases/vehicles/. Provides lookup helpers for the depot UI.
-///
-public class VehicleDatabase
-{
- public List Textures { get; } = new();
- public Dictionary GroupsById { get; } = new();
- public List Sets { get; } = new();
-
- /// Texture-uuid -> automatic-consist set that contains it.
- public Dictionary SetByTextureUuid { get; } = new();
-
- /// Texture-uuid -> texture (includes wrecks, so set refs resolve).
- public Dictionary TextureByUuid { get; } = new();
-
- /// skinfile (without extension, lower-case) -> texture.
- public Dictionary TextureBySkin { get; } = new();
-
- // System.Text.Json options matching the previous Newtonsoft leniency: property
- // names matched case-insensitively, // and /* */ comments skipped, and trailing
- // commas allowed. Unknown members are ignored by default. The source-generated
- // VehicleJsonContext resolver keeps deserialization AOT/trim-safe (no reflection).
- private static readonly JsonSerializerOptions JsonOpts = new()
- {
- PropertyNameCaseInsensitive = true,
- ReadCommentHandling = JsonCommentHandling.Skip,
- AllowTrailingCommas = true,
- TypeInfoResolver = VehicleJsonContext.Default,
- };
-
- ///
- /// Loads the database in one call. When a merged vehicles.json is present
- /// it is used, otherwise every per-vehicle *.json file is read.
- /// Never throws: unreadable files are skipped.
- ///
- public void Load(string directory = "databases/vehicles/")
- {
- BeginLoad();
- foreach (string file in EnumerateFiles(directory))
- LoadFile(file);
- EndLoad();
- }
-
- /// Resets the aggregate before an incremental load.
- public void BeginLoad()
- {
- Textures.Clear();
- GroupsById.Clear();
- Sets.Clear();
- SetByTextureUuid.Clear();
- TextureByUuid.Clear();
- TextureBySkin.Clear();
- }
-
- /// Finalises an incremental load (builds lookup indexes).
- public void EndLoad()
- {
- BuildSetIndex();
- BuildTextureIndex();
- }
-
- ///
- /// Files that make up the database: the merged vehicles.json if present,
- /// otherwise every per-vehicle *.json. Used to drive load progress.
- ///
- public static List EnumerateFiles(string directory = "databases/vehicles/")
- {
- if (!Directory.Exists(directory))
- return new List();
-
- string merged = Path.Combine(directory, "vehicles.json");
- if (File.Exists(merged))
- return new List { merged };
-
- return Directory.GetFiles(directory, "*.json").ToList();
- }
-
- /// Ingests a single database file (merged or per-vehicle).
- public void LoadFile(string file)
- {
- if (string.Equals(Path.GetFileName(file), "vehicles.json", StringComparison.OrdinalIgnoreCase))
- LoadMerged(file);
- else
- LoadEntryFile(file);
- }
-
- private void LoadMerged(string file)
- {
- try
- {
- var collection = JsonSerializer.Deserialize(
- File.ReadAllText(file),
- (JsonTypeInfo)JsonOpts.GetTypeInfo(typeof(VehicleEntryCollection)));
- if (collection?.Vehicles is null) return;
- foreach (var entry in collection.Vehicles)
- Ingest(entry);
- }
- catch
- {
- // ignore malformed merged database
- }
- }
-
- private void LoadEntryFile(string file)
- {
- try
- {
- var entry = JsonSerializer.Deserialize(
- File.ReadAllText(file),
- (JsonTypeInfo)JsonOpts.GetTypeInfo(typeof(VehicleEntry)));
- if (entry is not null)
- Ingest(entry);
- }
- catch
- {
- // ignore malformed single-vehicle file
- }
- }
-
- private void Ingest(VehicleEntry entry)
- {
- foreach (var group in entry.Groups)
- {
- if (!string.IsNullOrEmpty(group.Id))
- GroupsById.TryAdd(group.Id, group);
- }
-
- foreach (var texture in entry.Textures)
- {
- // index every texture (wrecks too) so set / skin references resolve
- if (!string.IsNullOrEmpty(texture.Uuid))
- TextureByUuid[texture.Uuid!] = texture;
- if (!string.IsNullOrEmpty(texture.Skinfile))
- TextureBySkin.TryAdd(Path.GetFileNameWithoutExtension(texture.Skinfile).ToLowerInvariant(), texture);
-
- // skip wrecks from the standard browser list
- if (texture.Wreck) continue;
- Textures.Add(texture);
- }
-
- Sets.AddRange(entry.Sets);
- }
-
- ///
- /// If the texture belongs to an automatic-consist set, returns all of that
- /// set's textures in their defined order; otherwise null.
- ///
- public List? ResolveSet(VehicleTexture texture)
- {
- if (string.IsNullOrEmpty(texture.Uuid)) return null;
- if (!SetByTextureUuid.TryGetValue(texture.Uuid!, out var set) || set.TextureRefs is null)
- return null;
-
- var cars = new List();
- foreach (string uuid in set.TextureRefs)
- {
- if (!string.IsNullOrEmpty(uuid) && TextureByUuid.TryGetValue(uuid, out var tex))
- cars.Add(tex);
- }
- return cars.Count > 0 ? cars : null;
- }
-
- private void BuildSetIndex()
- {
- foreach (var set in Sets)
- {
- if (set.TextureRefs is null) continue;
- foreach (string uuid in set.TextureRefs)
- {
- if (!string.IsNullOrEmpty(uuid))
- SetByTextureUuid[uuid] = set;
- }
- }
- }
-
- // Resolves each texture's class (group mini / mini_ref) and category letter
- // once, after all groups are loaded, so the depot reads cached fields instead
- // of doing a GroupsById lookup per texture on every search keystroke.
- private void BuildTextureIndex()
- {
- foreach (var t in Textures)
- {
- VehicleGroup? g = null;
- if (t.Group != null)
- GroupsById.TryGetValue(t.Group, out g);
-
- string mini = g != null && !string.IsNullOrEmpty(g.Mini)
- ? g.Mini!
- : t.MiniRef ?? "";
-
- string? category = g?.Category;
-
- // A "*" category is a wildcard: the actual category is the first letter
- // of the mini (upper-cased, like the original Starter's UpCase(mini[1])
- // wagon-typing), e.g. a "Bdhpumn" mini -> category "B".
- if (category == "*")
- {
- string source = !string.IsNullOrEmpty(mini) ? mini : (t.TextureMini ?? "");
- if (source.Length > 0)
- category = char.ToUpperInvariant(source[0]).ToString();
- }
-
- t.ResolvedCategory = category;
- t.ResolvedClass = mini;
- }
- }
-
- ///
- /// Miniature name for a texture: the texture_mini property, but if no .bmp
- /// for it exists, falls back to the mini of the group it belongs to.
- ///
- public string? ResolveMiniName(VehicleTexture texture)
- {
- if (!string.IsNullOrEmpty(texture.TextureMini) && MiniPath(texture.TextureMini) != null)
- return texture.TextureMini;
-
- if (texture.Group != null && GroupsById.TryGetValue(texture.Group, out var grp)
- && !string.IsNullOrEmpty(grp.Mini))
- return grp.Mini;
-
- return texture.TextureMini;
- }
-
- /// Resolved mini for a skin file (matched case-insensitively), or null.
- public string? MiniForSkin(string? skinFile)
- {
- if (string.IsNullOrEmpty(skinFile)) return null;
- string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
- return TextureBySkin.TryGetValue(key, out var tex) ? ResolveMiniName(tex) : null;
- }
-
- /// The texture for a skin file (matched case-insensitively), or null.
- public VehicleTexture? TextureForSkin(string? skinFile)
- {
- if (string.IsNullOrEmpty(skinFile)) return null;
- string key = Path.GetFileNameWithoutExtension(skinFile).ToLowerInvariant();
- return TextureBySkin.TryGetValue(key, out var tex) ? tex : null;
- }
-
- /// Header label for the group a texture belongs to.
- public string GroupHeader(string? groupId)
- {
- if (!string.IsNullOrEmpty(groupId) && GroupsById.TryGetValue(groupId, out var grp))
- {
- string mini = string.IsNullOrEmpty(grp.Mini) ? grp.Id : grp.Mini!;
- return string.IsNullOrEmpty(grp.Category) ? mini : $"{mini} ({grp.Category})";
- }
- return groupId ?? "";
- }
-
- // Case-insensitive index of mini .bmp files (built once).
- private static Dictionary? _miniIndex;
-
- ///
- /// Builds the miniature .bmp index up front (called from the startup load,
- /// behind the splash) so the first thumbnail render never stalls the UI
- /// thread scanning textures/mini/. No-op if it was already built.
- ///
- public static void PreloadMiniIndex(string miniDir = "textures/mini/") =>
- _miniIndex ??= BuildMiniIndex(miniDir);
-
- ///
- /// Resolves a miniature .bmp path under textures/mini/ case-insensitively,
- /// or null if missing. The directory is indexed once and reused.
- ///
- public static string? MiniPath(string? miniName, string miniDir = "textures/mini/")
- {
- if (string.IsNullOrEmpty(miniName)) return null;
-
- var index = _miniIndex ??= BuildMiniIndex(miniDir);
- return index.TryGetValue(miniName!.ToLowerInvariant(), out var path) ? path : null;
- }
-
- private static Dictionary BuildMiniIndex(string miniDir)
- {
- var index = new Dictionary();
- if (!Directory.Exists(miniDir))
- return index;
-
- foreach (string file in Directory.GetFiles(miniDir, "*.bmp"))
- index[Path.GetFileNameWithoutExtension(file).ToLowerInvariant()] = file;
- return index;
- }
-}
-
-/// Automatic consist definition (legacy ^x grouping).
-public class VehicleSet
-{
- [JsonPropertyName("uuid")] public string? Uuid { get; set; }
- [JsonPropertyName("mode")] public string? Mode { get; set; }
- [JsonPropertyName("count")] public int Count { get; set; }
- [JsonPropertyName("texture_refs")] public List TextureRefs { get; set; } = new();
-}
-
-// Source-generated JSON metadata — makes deserialization AOT/trim-safe by avoiding
-// runtime reflection over the model types. Nested types (VehicleGroup, VehicleTexture,
-// VehicleSet, VehicleAlias, VehicleMeta) are pulled in automatically by the generator.
-[JsonSerializable(typeof(VehicleEntry))]
-[JsonSerializable(typeof(VehicleEntryCollection))]
-internal partial class VehicleJsonContext : JsonSerializerContext
-{
-}
diff --git a/StarterNG/Controls/DetailRows.cs b/StarterNG/Controls/DetailRows.cs
new file mode 100644
index 0000000..706a895
--- /dev/null
+++ b/StarterNG/Controls/DetailRows.cs
@@ -0,0 +1,33 @@
+using Avalonia.Controls;
+
+namespace StarterNG.Controls;
+
+public static class DetailRows
+{
+ public const string NoValue = "–";
+
+ public static SettingRow Row(string? label = null, Control? content = null,
+ string? value = null, string? description = null,
+ string? tip = null)
+ {
+ var row = new SettingRow
+ {
+ Label = label,
+ Description = description,
+ ValueText = value,
+ Content = content
+ };
+ row.Classes.Add("Compact");
+
+ if (!string.IsNullOrWhiteSpace(tip))
+ ToolTip.SetTip(row, tip);
+
+ return row;
+ }
+
+ public static SettingRow Reading(string label, string? value)
+ {
+ bool empty = string.IsNullOrWhiteSpace(value);
+ return Row(label, value: empty ? NoValue : value, tip: empty ? null : value);
+ }
+}
diff --git a/StarterNG/Controls/SettingRow.cs b/StarterNG/Controls/SettingRow.cs
new file mode 100644
index 0000000..bee10d4
--- /dev/null
+++ b/StarterNG/Controls/SettingRow.cs
@@ -0,0 +1,69 @@
+using Avalonia;
+using Avalonia.Controls;
+
+namespace StarterNG.Controls;
+
+public class SettingRow : ContentControl
+{
+ public static readonly StyledProperty LabelProperty =
+ AvaloniaProperty.Register(nameof(Label));
+
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ public static readonly StyledProperty ConfigKeyProperty =
+ AvaloniaProperty.Register(nameof(ConfigKey));
+
+ public static readonly StyledProperty ValueTextProperty =
+ AvaloniaProperty.Register(nameof(ValueText));
+
+ public string? Label
+ {
+ get => GetValue(LabelProperty);
+ set => SetValue(LabelProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public string? ConfigKey
+ {
+ get => GetValue(ConfigKeyProperty);
+ set => SetValue(ConfigKeyProperty, value);
+ }
+
+ public string? ValueText
+ {
+ get => GetValue(ValueTextProperty);
+ set => SetValue(ValueTextProperty, value);
+ }
+
+ public SettingRow()
+ {
+ UpdatePseudoClasses();
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+ {
+ base.OnPropertyChanged(change);
+
+ if (change.Property == LabelProperty ||
+ change.Property == DescriptionProperty ||
+ change.Property == ConfigKeyProperty ||
+ change.Property == ValueTextProperty ||
+ change.Property == ContentProperty)
+ UpdatePseudoClasses();
+ }
+
+ private void UpdatePseudoClasses()
+ {
+ PseudoClasses.Set(":no-label", string.IsNullOrEmpty(Label));
+ PseudoClasses.Set(":no-description", string.IsNullOrEmpty(Description));
+ PseudoClasses.Set(":no-key", string.IsNullOrEmpty(ConfigKey));
+ PseudoClasses.Set(":no-value", string.IsNullOrEmpty(ValueText));
+ PseudoClasses.Set(":no-content", Content is null);
+ }
+}
diff --git a/StarterNG/Controls/SettingsSection.cs b/StarterNG/Controls/SettingsSection.cs
new file mode 100644
index 0000000..7cb092d
--- /dev/null
+++ b/StarterNG/Controls/SettingsSection.cs
@@ -0,0 +1,16 @@
+using Avalonia;
+using Avalonia.Controls.Primitives;
+
+namespace StarterNG.Controls;
+
+public class SettingsSection : HeaderedContentControl
+{
+ public static readonly StyledProperty