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/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
index e7f7f74..5269250 100644
--- a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
+++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs
@@ -15,6 +15,7 @@ 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);
@@ -115,6 +116,16 @@ public sealed class InMemoryFileSystem : IFileSystem
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
diff --git a/StarterNG.Tests/Fakes/TestInstallation.cs b/StarterNG.Tests/Fakes/TestInstallation.cs
index 81721bc..724a8d7 100644
--- a/StarterNG.Tests/Fakes/TestInstallation.cs
+++ b/StarterNG.Tests/Fakes/TestInstallation.cs
@@ -1,6 +1,7 @@
using StarterNG.Application;
using StarterNG.Infrastructure.Adapters;
using StarterNG.Infrastructure.Sceneries;
+using StarterNG.Infrastructure.Settings;
using StarterNG.Infrastructure.Vehicles;
namespace StarterNG.Tests.Fakes;
@@ -28,6 +29,11 @@ public sealed class TestInstallation
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; }
@@ -42,6 +48,10 @@ public sealed class TestInstallation
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/InstallationCheckTests.cs b/StarterNG.Tests/InstallationCheckTests.cs
index cea1b87..ffe1215 100644
--- a/StarterNG.Tests/InstallationCheckTests.cs
+++ b/StarterNG.Tests/InstallationCheckTests.cs
@@ -12,7 +12,7 @@ public class InstallationCheckTests
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.Physics, installation.Library, installation.SettingsStore);
var faults = check.Run();
@@ -35,7 +35,7 @@ public class InstallationCheckTests
var strings = installation.Strings.With("FaultNoDir", "missing {0}");
var faults = new InstallationCheck(installation.Paths, files, strings,
- installation.Physics, installation.Library).Run();
+ installation.Physics, installation.Library, installation.SettingsStore).Run();
Assert.DoesNotContain(faults, fault => fault.StartsWith("missing "));
Assert.DoesNotContain("FaultNoWeights", faults);
diff --git a/StarterNG.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs
new file mode 100644
index 0000000..7caaa1c
--- /dev/null
+++ b/StarterNG.Tests/SettingsStoreTests.cs
@@ -0,0 +1,131 @@
+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("Polski", settings.Language);
+ Assert.True(settings.LanguageWasSet);
+ Assert.Equal(UserIni, installation.SettingsStore.LoadedFrom);
+ }
+
+ [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/App.axaml.cs b/StarterNG/App.axaml.cs
index 6c78401..27954d5 100644
--- a/StarterNG/App.axaml.cs
+++ b/StarterNG/App.axaml.cs
@@ -29,20 +29,20 @@ public partial class App : Avalonia.Application
{
AvaloniaXamlLoader.Load(this);
- Settings.Instance.Load();
+ AppServices.Current.SettingsStore.Load();
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;
ApplyLanguage(langName);
}
@@ -51,7 +51,7 @@ public partial class App : Avalonia.Application
{
var resolvedName = Loc.Load(langNameOrCode);
- Settings.Instance.Language = resolvedName;
+ AppServices.Current.Settings.Language = resolvedName;
ApplyCulture(Loc.CurrentLangCode);
}
@@ -87,10 +87,10 @@ public partial class App : Avalonia.Application
desktop.ShutdownRequested += (_, _) =>
{
- try { Settings.Instance.CaptureAndSave(); }
+ try { AppServices.Current.SettingsStore.CaptureAndSave(); }
catch (Exception ex) { Diagnostics.Log("Settings save", ex); }
- Settings.Instance.DumpMissingVehicleLog();
+ AppServices.Current.MissingVehicleLog.Dump();
try
{
diff --git a/StarterNG/Application/Abstractions/IEnvironment.cs b/StarterNG/Application/Abstractions/IEnvironment.cs
new file mode 100644
index 0000000..aad2b85
--- /dev/null
+++ b/StarterNG/Application/Abstractions/IEnvironment.cs
@@ -0,0 +1,20 @@
+namespace StarterNG.Application.Abstractions;
+
+///
+/// The bits of the host machine the starter has to ask about: which operating
+/// system it is on, and where that system keeps per-user configuration.
+///
+public interface IEnvironment
+{
+ bool IsWindows { get; }
+
+ bool IsLinux { get; }
+
+ bool IsMacOS { get; }
+
+ /// An environment variable, or null when it is not set.
+ string? GetVariable(string name);
+
+ /// The folder the starter's own executable lives in.
+ string BaseDirectory { get; }
+}
diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs
index e380c3a..fe381c5 100644
--- a/StarterNG/Application/Abstractions/IFileSystem.cs
+++ b/StarterNG/Application/Abstractions/IFileSystem.cs
@@ -58,4 +58,10 @@ public interface IFileSystem
IReadOnlyList GetFilesRecursive(string path, string searchPattern);
DateTime GetLastWriteTimeUtc(string path);
+
+ ///
+ /// Whether the file carries an execute bit. Always true on Windows, where
+ /// executability is a property of the file's contents rather than its mode.
+ ///
+ bool IsExecutable(string path);
}
diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs
index 6b166fe..201b53b 100644
--- a/StarterNG/Application/AppServices.cs
+++ b/StarterNG/Application/AppServices.cs
@@ -1,7 +1,9 @@
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;
@@ -27,11 +29,12 @@ public sealed class AppServices
{
private static AppServices? _current;
- private AppServices(IGamePaths paths, IFileSystem files, IClock clock)
+ 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;
@@ -48,6 +51,12 @@ public sealed class AppServices
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);
}
///
@@ -60,12 +69,14 @@ public sealed class AppServices
"AppServices.Current read before Initialize(); the composition root must be built first.");
/// Builds the production graph. Called once, from the entry point.
- public static AppServices Initialize(IGamePaths? paths = null, IFileSystem? files = null, IClock? clock = null)
+ 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());
+ clock ?? new SystemClock(),
+ environment ?? new SystemEnvironment());
return _current;
}
@@ -75,6 +86,8 @@ public sealed class AppServices
public IClock Clock { get; }
+ public IEnvironment Environment { get; }
+
public IDiagnosticsLog Log { get; }
public IProcessLauncher Processes { get; }
@@ -103,6 +116,18 @@ public sealed class AppServices
/// What the user currently has selected.
public AppState State { get; }
+ /// The settings the starter runs with, and their load/save lifecycle.
+ public SettingsStore SettingsStore { get; }
+
+ /// Shorthand for the settings themselves.
+ public SimulatorSettings Settings => SettingsStore.Settings;
+
+ public ExecutableLocator Executables { get; }
+
+ public SettingsPaths SettingsPaths { get; }
+
+ public MissingVehicleLog MissingVehicleLog { get; }
+
///
/// Exposed as the concrete service rather than
/// because the XAML binds to it as a change-notifying source; code should take
diff --git a/StarterNG/Application/MissingVehicleLog.cs b/StarterNG/Application/MissingVehicleLog.cs
new file mode 100644
index 0000000..fa4de6c
--- /dev/null
+++ b/StarterNG/Application/MissingVehicleLog.cs
@@ -0,0 +1,49 @@
+using System;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Settings;
+using StarterNG.Infrastructure.Vehicles;
+
+namespace StarterNG.Application;
+
+///
+/// Writes the "vehicle files that are not there" report into the diagnostics
+/// log, when the user has asked for it.
+///
+///
+/// Was a method on the settings object, which had no business scanning the
+/// installation. The setting decides whether it runs; the scan itself belongs to
+/// the vehicle side.
+///
+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..9edb3d0
--- /dev/null
+++ b/StarterNG/Application/SettingsStore.cs
@@ -0,0 +1,191 @@
+using System;
+using System.IO;
+using System.Text;
+using StarterNG.Application.Abstractions;
+using StarterNG.Classes;
+using StarterNG.Domain.Settings;
+using StarterNG.Infrastructure.Settings;
+
+namespace StarterNG.Application;
+
+///
+/// A screen that holds settings the user is editing and can be asked to write
+/// them back into the model before a save.
+///
+///
+/// Replaces the mutable Action? CaptureFromUi the settings model used to
+/// expose: the dependency now points the right way, from the store to an
+/// interface the view implements.
+///
+public interface ISettingsCapture
+{
+ void CaptureInto(SimulatorSettings settings);
+}
+
+///
+/// Owns the settings the starter runs with: where they were read from, when to
+/// write them back, and which executable they point at.
+///
+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 = LegacyEncoding();
+ }
+
+ /// The settings the application is running with.
+ public SimulatorSettings Settings { get; } = new();
+
+ /// The file the current settings were read from.
+ public string LoadedFrom { get; private set; } = string.Empty;
+
+ /// The file a save will write to.
+ public string SavePath => _savePath;
+
+ ///
+ /// Registers the screen that holds pending edits, so a save can collect them
+ /// first. Called by the settings view when it is created.
+ ///
+ public void RegisterCapture(ISettingsCapture capture) => _capture = capture;
+
+ ///
+ /// Reads the user's settings, falling back to the copy shipped with the
+ /// installation on a first run.
+ ///
+ 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)
+ {
+ // An unreadable ini means defaults, not a failed start.
+ _log.Log($"reading {path}", ex);
+ _config = new ConfigFile();
+ }
+
+ _serializer.Read(Settings, _config);
+ TouchConfigAge();
+ }
+
+ /// Collects pending edits from the settings screen, then saves.
+ 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);
+ }
+ }
+
+ /// Records the ini's timestamp, so an outside edit can be noticed.
+ 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;
+ }
+ }
+
+ ///
+ /// True when something else has written the ini since we last touched it -
+ /// the simulator itself, usually, which the starter must not clobber.
+ ///
+ public bool IsConfigNewerOnDisk()
+ {
+ string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath;
+ try
+ {
+ if (!_files.FileExists(path) || _configAgeUtc == DateTime.MinValue)
+ return false;
+
+ // A second of slack: writing our own file must not count as an edit.
+ 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);
+
+ ///
+ /// The .ini keeps its own code page. UTF-8 is a safe fallback if the code
+ /// page provider is unavailable.
+ ///
+ private static Encoding LegacyEncoding()
+ {
+ try
+ {
+ Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
+ return Encoding.GetEncoding(1250);
+ }
+ catch (Exception)
+ {
+ return Encoding.UTF8;
+ }
+ }
+}
diff --git a/StarterNG/Classes/Settings.cs b/StarterNG/Classes/Settings.cs
deleted file mode 100644
index 9d093df..0000000
--- a/StarterNG/Classes/Settings.cs
+++ /dev/null
@@ -1,699 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Globalization;
-using System.IO;
-using System.Linq;
-using System.Text;
-using StarterNG.Application;
-
-namespace StarterNG.Classes;
-
-public enum BatteryDefault { Default = 0, AlwaysOff = 1, AlwaysOn = 2 }
-
-public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform }
-
-public sealed class Settings
-{
- public static Settings Instance { get; } = new();
-
- public Action? CaptureFromUi { get; set; }
-
- public bool LanguageWasSet { get; private set; }
-
- public string Language = "English";
- public bool Fullscreen;
- public bool PauseWhenInactive = true;
- public bool PauseOnStart;
- public int CursorSensitivity = 2;
- public bool InvertMouseHorizontal;
- public bool InvertMouseVertical;
-
- public bool IgnoreGamepad;
- public int FeedbackMode = 1;
- public int FeedbackPort = 888;
- public bool UartEnabled;
- public string UartPort = "";
- public string UartTune = "";
- public bool UartDebug;
- public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel;
-
- public bool SelectExeAutomatically = true;
- public string ExecutablePath = "eu07.exe";
- public bool DebugMode;
- public bool VirtualShunting = true;
- public bool LogMissingVehicleFiles;
-
- public int RenderEngine;
- public int Width = 1280;
- public int Height = 720;
- public int BufferScalePercent = 100;
- public int MaxTextureSize = 4096;
- public int MaxCabTextureSize = 4096;
- public int TextureFiltering = 8;
- public int Multisampling = 2;
- public int DynamicLights = 7;
- public double DrawRangeFactor = 3.0;
- public bool VSync;
- public bool Smoke = true;
- public int SmokeFidelity = 1;
- public bool ChromaticAberration = true;
- public bool MotionBlur = true;
- public bool ExtraEffects = true;
- public bool EnvMap = true;
- public bool UseVbo = true;
- public bool RenderShadows = true;
- public int ReflectionsFramerate = 60;
- public int ShadowMapResolution = 2048;
- public int ShadowProjectionRange = 250;
- public int CabShadowsRange;
- public int ShadowRankCutoff = 3;
- public int ReflectionsFidelity;
- public int FieldOfView = 45;
- public bool PythonScreens = true;
- public bool PythonThreadedUpload = true;
- public bool FpsLimitEnabled;
- public int FpsLimit = 30;
- public double ShadowAngleLimit = 0.2;
- public bool FullscreenWindowed;
- public bool RenderAngleVulkan;
-
- public int SplineFidelity = 1;
- public bool FullPhysics = true;
- public bool EnableTraction = true;
- public bool LiveTraction = true;
- public bool PhysicsLog;
- public bool DebugLog = true;
- public bool MultipleLogs;
- public bool DisplaySimulation = true;
- public bool CrashDamage = true;
- public double Friction = 1.0;
- public double BrakeStep = 1.0;
- public double BrakeSpeed = 3.0;
-
- public bool SoundEnabled = true;
- public int Volume = 50;
- public int RadioVolume = 75;
- public int VehiclesVolume = 100;
- public int PositionalVolume = 100;
- public int AmbientVolume = 100;
- public bool SkipPipeline;
- public int PythonScreenUpdateRate = 200;
- public bool DebugLogVisible;
- private int _debugLogExtraBits;
-
- public bool CompressTextures = true;
- public bool ScaleSpeculars = true;
- public bool UseGLES;
- public bool ShaderGamma;
- public bool ScreenMipmaps = true;
- public bool ExtendedModelConversion;
- public bool GfxResourceMove;
- public bool GfxResourceSweep = true;
- public int TrainPhysicsThreads;
- public bool IgnoreIrrelevantTrains;
-
- public bool AutoCloseStarter;
- public bool LargeThumbnails;
- public bool AutoExpandSceneryTree;
- public BatteryDefault BatteryDefault = BatteryDefault.Default;
-
- public bool ShowAiVehicles = true;
- public bool DrivableOnly = true;
- public bool ShowArchivalSceneries = true;
- public bool HideArchivalVehicles = true;
- public string LastScenery = "";
-
- ///
- /// Starter's own window state, the way the old one kept WindowMaximized in
- /// starter.ini. Only the maximised flag is remembered - a normal window
- /// always opens at its design size, centred on the screen.
- ///
- public bool WindowMaximized;
-
- public static readonly string[] RenderEngines =
- { "full", "legacy", "simpleshader", "simple", "off", "experimental" };
-
- public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 };
-
- private double _mouseScaleYMagnitude = 0.2;
- private string[] _shadowTuneExtra = { "400", "300" };
-
- private ConfigFile _config = new();
- private string _savePath = string.Empty;
- private DateTime _configAgeUtc = DateTime.MinValue;
-
- public string LoadedFrom { get; private set; } = string.Empty;
-
- public string SavePath => _savePath;
-
- 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");
- }
-
- return Path.Combine(AppContext.BaseDirectory, "eu07.ini");
- }
-
- private static string DefaultConfigPath() =>
- Path.Combine(Directory.GetCurrentDirectory(), "eu07.ini");
-
- public static string UserConfigDirectory() =>
- Path.GetDirectoryName(UserConfigPath()) ?? AppContext.BaseDirectory;
-
- public string ResolveExecutable() => ResolveExecutable(out _);
-
- public string ResolveExecutable(out ExeProblem problem)
- {
- if (!SelectExeAutomatically && !string.IsNullOrWhiteSpace(ExecutablePath))
- {
- problem = ValidateExecutable(FullPath(ExecutablePath));
- return ExecutablePath;
- }
-
- string canonical = OperatingSystem.IsWindows() ? "eu07.exe" : "eu07";
- string? fallback = null;
- try
- {
- var candidates = Directory.GetFiles(Directory.GetCurrentDirectory(), "eu07*")
- .Where(IsExecutableCandidate)
- .OrderBy(p => string.Equals(Path.GetFileName(p), canonical, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
- .ThenBy(p => Path.GetFileName(p)!.Length);
-
- foreach (string path in candidates)
- {
- if (ValidateExecutable(path) == ExeProblem.None)
- {
- problem = ExeProblem.None;
- return path;
- }
- fallback ??= path;
- }
- }
- catch (Exception ex)
- {
- StarterNG.Infrastructure.Diagnostics.Log("looking for the simulator executable", ex);
- }
-
- if (fallback != null)
- {
- problem = ValidateExecutable(fallback);
- return fallback;
- }
-
- problem = ExeProblem.NotFound;
- return canonical;
- }
-
- private static string FullPath(string path) =>
- Path.IsPathRooted(path) ? path : Path.Combine(Directory.GetCurrentDirectory(), path);
-
- public static ExeProblem ValidateExecutable(string path)
- {
- try
- {
- if (!File.Exists(path))
- return ExeProblem.NotFound;
-
- if (!OperatingSystem.IsWindows())
- {
- const UnixFileMode anyExecute =
- UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
- if ((File.GetUnixFileMode(path) & anyExecute) == 0)
- return ExeProblem.NotExecutable;
- }
-
- byte[] head = new byte[4];
- using (var fs = File.OpenRead(path))
- {
- if (fs.Read(head, 0, 4) == 4)
- {
- bool pe = head[0] == 0x4D && head[1] == 0x5A;
- bool elf = head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46;
- if (OperatingSystem.IsWindows() && elf)
- return ExeProblem.WrongPlatform;
- if (OperatingSystem.IsLinux() && pe)
- return ExeProblem.WrongPlatform;
- }
- }
-
- return ExeProblem.None;
- }
- catch
- {
- return ExeProblem.NotFound;
- }
- }
-
- 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(); }
- }
-
- 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";
- if (OperatingSystem.IsLinux())
- return ext.Length == 0 || ext is ".x86_64" or ".run" or ".appimage";
-
- return ext.Length == 0 || ext is ".exe" or ".x86_64" or ".run" or ".appimage";
- }
-
- private static readonly Encoding FileEncoding = ResolveEncoding();
-
- private static Encoding ResolveEncoding()
- {
-
- try
- {
- Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
- return Encoding.GetEncoding(1250);
- }
- catch
- {
- return Encoding.UTF8;
- }
- }
-
- public void Load()
- {
- _savePath = UserConfigPath();
- string source = File.Exists(_savePath) ? _savePath : DefaultConfigPath();
- LoadedFrom = source;
- LoadFrom(source);
- }
-
- public void CaptureAndSave()
- {
- CaptureFromUi?.Invoke();
- Save();
- }
-
- public void LoadFrom(string path)
- {
- try
- {
- _config = File.Exists(path)
- ? ConfigFile.Parse(File.ReadAllText(path, FileEncoding))
- : new ConfigFile();
- }
- catch
- {
- _config = new ConfigFile();
- }
-
- ReadFromConfig();
- TouchConfigAge();
- }
-
- public void Save()
- {
- if (string.IsNullOrEmpty(_savePath))
- _savePath = UserConfigPath();
-
- SaveTo(_savePath);
- }
-
- 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);
- TouchConfigAge();
- }
- catch (Exception ex)
- {
- StarterNG.Infrastructure.Diagnostics.Log($"saving {path}", ex);
- }
- }
-
- public void TouchConfigAge()
- {
- string path = string.IsNullOrEmpty(_savePath) ? UserConfigPath() : _savePath;
- try
- {
- _configAgeUtc = File.Exists(path)
- ? File.GetLastWriteTimeUtc(path)
- : DateTime.UtcNow;
- }
- catch
- {
- _configAgeUtc = DateTime.UtcNow;
- }
- }
-
- public bool IsConfigNewerOnDisk()
- {
- string path = string.IsNullOrEmpty(_savePath) ? UserConfigPath() : _savePath;
- try
- {
- if (!File.Exists(path) || _configAgeUtc == DateTime.MinValue)
- return false;
- return File.GetLastWriteTimeUtc(path) > _configAgeUtc + TimeSpan.FromSeconds(1);
- }
- catch
- {
- return false;
- }
- }
-
- public void DumpMissingVehicleLog()
- {
- if (!LogMissingVehicleFiles) return;
- try
- {
- var lines = AppServices.Current.MissingAssets.Scan(AppServices.Current.Library.Vehicles);
- if (lines.Count == 0) return;
- Infrastructure.Diagnostics.Log(lines);
- }
- catch (Exception ex)
- {
- Infrastructure.Diagnostics.Log("Missing vehicle log", ex);
- }
- }
-
- private void ReadFromConfig()
- {
- var c = _config;
-
- 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);
- }
-
- 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;
- }
-
- 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);
-
- 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);
-
- 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);
- int debugLog = c.GetInt("debuglog", 3);
- DebugLog = (debugLog & 1) != 0;
- DebugLogVisible = (debugLog & 2) != 0;
- _debugLogExtraBits = debugLog & ~3;
- 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);
-
- 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);
- SkipPipeline = c.GetBool("gfx.skippipeline", false);
- PythonScreenUpdateRate = c.Has("python.updatetime")
- ? Clamp(c.GetInt("python.updatetime", 200), 0, 10000)
- : MapOldPyScreenPriority(c.GetString("pyscreenrendererpriority", "normal"));
-
- 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);
-
- 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);
- HideArchivalVehicles = c.GetBool("starter.hide.archivalvehicles", true);
- LastScenery = c.GetString("starter.last.scenery", "");
- WindowMaximized = c.GetBool("starter.window.maximized", false);
- }
-
- private static readonly string[] ObsoleteKeys =
- {
- "gfx.postfx.tonemapping",
- };
-
- private void WriteToConfig()
- {
- var c = _config;
-
- foreach (string key in ObsoleteKeys)
- c.Remove(key);
-
- 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)}");
-
- 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());
-
- 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);
-
- c.Set("gfxrenderer", RenderEngines[Clamp(RenderEngine, 0, RenderEngines.Length - 1)]);
- c.SetInt("width", Width);
- c.SetInt("height", Height);
- c.SetInt("starter.bufferscale", BufferScalePercent);
- int fbw = (int)Math.Round(Width * BufferScalePercent / 100.0);
- int fbh = (int)Math.Round(Height * BufferScalePercent / 100.0);
- c.SetInt("gfx.framebuffer.width", fbw);
- c.SetInt("gfx.framebuffer.height", fbh);
- 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");
-
- c.SetInt("splinefidelity", SplineFidelity);
- c.SetBool("fullphysics", FullPhysics);
- c.SetBool("enabletraction", EnableTraction);
- c.SetBool("livetraction", LiveTraction);
- c.SetBool("physicslog", PhysicsLog);
- c.SetInt("debuglog",
- (DebugLog ? 1 : 0) | (DebugLogVisible ? 2 : 0) | _debugLogExtraBits);
- 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);
-
- 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);
- c.SetBool("gfx.skippipeline", SkipPipeline);
- c.SetInt("python.updatetime", PythonScreenUpdateRate);
-
- 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);
-
- 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);
- c.SetBool("starter.hide.archivalvehicles", HideArchivalVehicles);
- if (!string.IsNullOrWhiteSpace(LastScenery))
- c.Set("starter.last.scenery", LastScenery);
- else
- c.Remove("starter.last.scenery");
- c.SetBool("starter.window.maximized", WindowMaximized);
- }
-
- 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;
- }
-
- private static int MapOldPyScreenPriority(string priority) =>
- priority.ToLowerInvariant() switch
- {
- "lower" => 400,
- "lowest" => 800,
- "idle" => 1600,
- "off" => 0,
- _ => 200
- };
-}
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/Domain/Settings/SimulatorSettings.cs b/StarterNG/Domain/Settings/SimulatorSettings.cs
new file mode 100644
index 0000000..e671649
--- /dev/null
+++ b/StarterNG/Domain/Settings/SimulatorSettings.cs
@@ -0,0 +1,161 @@
+using System;
+
+namespace StarterNG.Domain.Settings;
+
+/// How the simulator should treat the battery switch when a run starts.
+public enum BatteryDefault { Default = 0, AlwaysOff = 1, AlwaysOn = 2 }
+
+/// What is wrong with the executable the starter would launch.
+public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform }
+
+///
+/// Everything the starter writes into eu07.ini, plus the handful of preferences
+/// it keeps for itself.
+///
+///
+/// Plain state with sensible defaults, and nothing else: reading and writing the
+/// ini is Infrastructure.Settings.SettingsSerializer, locating the file
+/// and the executable is the rest of that folder, and the load/save lifecycle is
+/// Application.SettingsStore. Splitting those out is what makes it
+/// possible to test the ini mapping without a disk.
+///
+public sealed class SimulatorSettings
+{
+ ///
+ /// False until the ini names a language, which is how the first run knows to
+ /// follow the operating system instead.
+ ///
+ public bool LanguageWasSet { get; internal set; }
+
+ public string Language = "English";
+ public bool Fullscreen;
+ public bool PauseWhenInactive = true;
+ public bool PauseOnStart;
+ public int CursorSensitivity = 2;
+ public bool InvertMouseHorizontal;
+ public bool InvertMouseVertical;
+
+ public bool IgnoreGamepad;
+ public int FeedbackMode = 1;
+ public int FeedbackPort = 888;
+ public bool UartEnabled;
+ public string UartPort = "";
+ public string UartTune = "";
+ public bool UartDebug;
+ public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel;
+
+ public bool SelectExeAutomatically = true;
+ public string ExecutablePath = "eu07.exe";
+ public bool DebugMode;
+ public bool VirtualShunting = true;
+ public bool LogMissingVehicleFiles;
+
+ public int RenderEngine;
+ public int Width = 1280;
+ public int Height = 720;
+ public int BufferScalePercent = 100;
+ public int MaxTextureSize = 4096;
+ public int MaxCabTextureSize = 4096;
+ public int TextureFiltering = 8;
+ public int Multisampling = 2;
+ public int DynamicLights = 7;
+ public double DrawRangeFactor = 3.0;
+ public bool VSync;
+ public bool Smoke = true;
+ public int SmokeFidelity = 1;
+ public bool ChromaticAberration = true;
+ public bool MotionBlur = true;
+ public bool ExtraEffects = true;
+ public bool EnvMap = true;
+ public bool UseVbo = true;
+ public bool RenderShadows = true;
+ public int ReflectionsFramerate = 60;
+ public int ShadowMapResolution = 2048;
+ public int ShadowProjectionRange = 250;
+ public int CabShadowsRange;
+ public int ShadowRankCutoff = 3;
+ public int ReflectionsFidelity;
+ public int FieldOfView = 45;
+ public bool PythonScreens = true;
+ public bool PythonThreadedUpload = true;
+ public bool FpsLimitEnabled;
+ public int FpsLimit = 30;
+ public double ShadowAngleLimit = 0.2;
+ public bool FullscreenWindowed;
+ public bool RenderAngleVulkan;
+
+ public int SplineFidelity = 1;
+ public bool FullPhysics = true;
+ public bool EnableTraction = true;
+ public bool LiveTraction = true;
+ public bool PhysicsLog;
+ public bool DebugLog = true;
+ public bool MultipleLogs;
+ public bool DisplaySimulation = true;
+ public bool CrashDamage = true;
+ public double Friction = 1.0;
+ public double BrakeStep = 1.0;
+ public double BrakeSpeed = 3.0;
+
+ public bool SoundEnabled = true;
+ public int Volume = 50;
+ public int RadioVolume = 75;
+ public int VehiclesVolume = 100;
+ public int PositionalVolume = 100;
+ public int AmbientVolume = 100;
+ public bool SkipPipeline;
+ public int PythonScreenUpdateRate = 200;
+ public bool DebugLogVisible;
+ ///
+ /// Debug log bits beyond the two the UI exposes, carried through a round trip
+ /// so a hand-configured log level is not silently narrowed.
+ ///
+ internal int DebugLogExtraBits { get; set; }
+
+ public bool CompressTextures = true;
+ public bool ScaleSpeculars = true;
+ public bool UseGLES;
+ public bool ShaderGamma;
+ public bool ScreenMipmaps = true;
+ public bool ExtendedModelConversion;
+ public bool GfxResourceMove;
+ public bool GfxResourceSweep = true;
+ public int TrainPhysicsThreads;
+ public bool IgnoreIrrelevantTrains;
+
+ public bool AutoCloseStarter;
+ public bool LargeThumbnails;
+ public bool AutoExpandSceneryTree;
+ public BatteryDefault BatteryDefault = BatteryDefault.Default;
+
+ public bool ShowAiVehicles = true;
+ public bool DrivableOnly = true;
+ public bool ShowArchivalSceneries = true;
+ public bool HideArchivalVehicles = true;
+ public string LastScenery = "";
+
+ ///
+ /// Starter's own window state, the way the old one kept WindowMaximized in
+ /// starter.ini. Only the maximised flag is remembered - a normal window
+ /// always opens at its design size, centred on the screen.
+ ///
+ public bool WindowMaximized;
+
+ public static readonly string[] RenderEngines =
+ { "full", "legacy", "simpleshader", "simple", "off", "experimental" };
+
+ public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 };
+
+ ///
+ /// Vertical mouse scale as the ini stored it. The UI only exposes one
+ /// sensitivity, so the authored vertical magnitude is carried through a
+ /// round trip rather than being overwritten.
+ ///
+ internal double MouseScaleYMagnitude { get; set; } = 0.2;
+
+ ///
+ /// Shadow tuning values beyond the two the UI edits, carried through a round
+ /// trip unchanged.
+ ///
+ internal string[] ShadowTuneExtra { get; set; } = { "400", "300" };
+}
diff --git a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
index f1751da..2a3c295 100644
--- a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
+++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs
@@ -52,4 +52,14 @@ public sealed class PhysicalFileSystem : IFileSystem
Directory.Exists(path) ? Directory.GetDirectories(path) : Array.Empty();
public DateTime GetLastWriteTimeUtc(string path) => File.GetLastWriteTimeUtc(path);
+
+ public bool IsExecutable(string path)
+ {
+ if (OperatingSystem.IsWindows())
+ return true;
+
+ const UnixFileMode anyExecute =
+ UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
+ return (File.GetUnixFileMode(path) & anyExecute) != 0;
+ }
}
diff --git a/StarterNG/Infrastructure/Adapters/SystemEnvironment.cs b/StarterNG/Infrastructure/Adapters/SystemEnvironment.cs
new file mode 100644
index 0000000..e4b4f7e
--- /dev/null
+++ b/StarterNG/Infrastructure/Adapters/SystemEnvironment.cs
@@ -0,0 +1,18 @@
+using System;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Infrastructure.Adapters;
+
+/// backed by the running process.
+public sealed class SystemEnvironment : IEnvironment
+{
+ public bool IsWindows => OperatingSystem.IsWindows();
+
+ public bool IsLinux => OperatingSystem.IsLinux();
+
+ public bool IsMacOS => OperatingSystem.IsMacOS();
+
+ public string? GetVariable(string name) => Environment.GetEnvironmentVariable(name);
+
+ public string BaseDirectory => AppContext.BaseDirectory;
+}
diff --git a/StarterNG/Infrastructure/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs
index a8d45e7..e5bbc61 100644
--- a/StarterNG/Infrastructure/Diagnostics.cs
+++ b/StarterNG/Infrastructure/Diagnostics.cs
@@ -72,6 +72,7 @@ public static class Diagnostics
AppServices.Current.Files,
AppServices.Current.Localization,
AppServices.Current.Physics,
- AppServices.Current.Library)
+ AppServices.Current.Library,
+ AppServices.Current.SettingsStore)
.Run();
}
diff --git a/StarterNG/Infrastructure/InstallationCheck.cs b/StarterNG/Infrastructure/InstallationCheck.cs
index f0c9706..e6d1824 100644
--- a/StarterNG/Infrastructure/InstallationCheck.cs
+++ b/StarterNG/Infrastructure/InstallationCheck.cs
@@ -3,6 +3,7 @@ using System.IO;
using StarterNG.Application.Abstractions;
using StarterNG.Classes;
using StarterNG.Application;
+using StarterNG.Domain.Settings;
namespace StarterNG.Infrastructure;
@@ -23,15 +24,17 @@ public sealed class InstallationCheck
private readonly ILocalizedStrings _strings;
private readonly IPhysicsRepository _physics;
private readonly GameLibrary _library;
+ private readonly SettingsStore _settings;
public InstallationCheck(IGamePaths paths, IFileSystem files, ILocalizedStrings strings,
- IPhysicsRepository physics, GameLibrary library)
+ IPhysicsRepository physics, GameLibrary library, SettingsStore settings)
{
_paths = paths;
_files = files;
_strings = strings;
_physics = physics;
_library = library;
+ _settings = settings;
}
public List Run()
@@ -54,7 +57,8 @@ public sealed class InstallationCheck
if (_physics.IndexedCount == 0)
faults.Add(_strings["FaultNoPhysics"]);
- if (Settings.Instance.ResolveExecutable(out var problem) is var _ && problem == ExeProblem.NotFound)
+ _settings.ResolveExecutable(out var problem);
+ if (problem == ExeProblem.NotFound)
faults.Add(_strings["FaultNoExe"]);
return faults;
diff --git a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs
new file mode 100644
index 0000000..7521cff
--- /dev/null
+++ b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs
@@ -0,0 +1,166 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using StarterNG.Application.Abstractions;
+using StarterNG.Domain.Settings;
+
+namespace StarterNG.Infrastructure.Settings;
+
+///
+/// Finds and vets the simulator executable the starter will launch.
+///
+///
+/// Installations ship the binary under several names (eu07.exe, eu07,
+/// eu07-vulkan, ...), and a folder shared between platforms may hold both a PE
+/// and an ELF build. So a candidate is not just found by name: it is checked for
+/// the execute bit and for a magic number matching the platform we are on.
+///
+public sealed class ExecutableLocator
+{
+ /// Extensions that are never the simulator, whatever they are named.
+ private static readonly string[] NeverExecutable =
+ {
+ ".ini", ".log", ".txt", ".cfg", ".config", ".json", ".dll", ".so", ".dat", ".bak", ".csv", ".xml"
+ };
+
+ private readonly IFileSystem _files;
+ private readonly IGamePaths _paths;
+ private readonly IEnvironment _environment;
+ private readonly IDiagnosticsLog _log;
+
+ public ExecutableLocator(IFileSystem files, IGamePaths paths, IEnvironment environment, IDiagnosticsLog log)
+ {
+ _files = files;
+ _paths = paths;
+ _environment = environment;
+ _log = log;
+ }
+
+ /// The canonical name for the platform, used when nothing is found.
+ public string CanonicalName => _environment.IsWindows ? "eu07.exe" : "eu07";
+
+ ///
+ /// The executable to launch for these settings, along with what is wrong with
+ /// it. A path is always returned so the UI can show what it was looking for.
+ ///
+ public string Resolve(SimulatorSettings settings, out ExeProblem problem)
+ {
+ if (!settings.SelectExeAutomatically && !string.IsNullOrWhiteSpace(settings.ExecutablePath))
+ {
+ problem = Validate(FullPath(settings.ExecutablePath));
+ return settings.ExecutablePath;
+ }
+
+ string? fallback = null;
+ try
+ {
+ // Prefer the canonical name, then the shortest: "eu07" over "eu07-dev".
+ var candidates = _files.GetFiles(_paths.Root, "eu07*")
+ .Where(IsCandidate)
+ .OrderBy(path => string.Equals(Path.GetFileName(path), CanonicalName,
+ StringComparison.OrdinalIgnoreCase) ? 0 : 1)
+ .ThenBy(path => Path.GetFileName(path).Length);
+
+ foreach (string path in candidates)
+ {
+ if (Validate(path) == ExeProblem.None)
+ {
+ problem = ExeProblem.None;
+ return path;
+ }
+ fallback ??= path;
+ }
+ }
+ catch (Exception ex)
+ {
+ _log.Log("looking for the simulator executable", ex);
+ }
+
+ if (fallback is not null)
+ {
+ problem = Validate(fallback);
+ return fallback;
+ }
+
+ problem = ExeProblem.NotFound;
+ return CanonicalName;
+ }
+
+ /// Executables the user can pick from, most recently built first.
+ public List ListCandidates(string? directory = null)
+ {
+ try
+ {
+ return _files.GetFiles(directory ?? _paths.Root, "eu07*")
+ .Where(IsCandidate)
+ .OrderByDescending(_files.GetLastWriteTimeUtc)
+ .Select(Path.GetFileName)
+ .ToList()!;
+ }
+ catch (Exception)
+ {
+ return new List();
+ }
+ }
+
+ public ExeProblem Validate(string path)
+ {
+ try
+ {
+ if (!_files.FileExists(path))
+ return ExeProblem.NotFound;
+
+ if (!_environment.IsWindows && !_files.IsExecutable(path))
+ return ExeProblem.NotExecutable;
+
+ return MatchesPlatform(path) ? ExeProblem.None : ExeProblem.WrongPlatform;
+ }
+ catch (Exception)
+ {
+ return ExeProblem.NotFound;
+ }
+ }
+
+ ///
+ /// Reads the file's magic number: "MZ" for a Windows binary, 0x7F "ELF" for a
+ /// Linux one. A file we cannot read four bytes from is given the benefit of
+ /// the doubt.
+ ///
+ private bool MatchesPlatform(string path)
+ {
+ byte[] head = new byte[4];
+ using var stream = _files.OpenRead(path);
+ if (stream.Read(head, 0, 4) != 4)
+ return true;
+
+ bool portableExecutable = head[0] == 0x4D && head[1] == 0x5A;
+ bool elf = head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46;
+
+ if (_environment.IsWindows && elf)
+ return false;
+ if (_environment.IsLinux && portableExecutable)
+ return false;
+
+ return true;
+ }
+
+ private bool IsCandidate(string path)
+ {
+ string extension = Path.GetExtension(path).ToLowerInvariant();
+
+ if (NeverExecutable.Contains(extension))
+ return false;
+
+ if (_environment.IsWindows)
+ return extension == ".exe";
+
+ if (_environment.IsLinux)
+ return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage";
+
+ return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage";
+ }
+
+ private string FullPath(string path) =>
+ Path.IsPathRooted(path) ? path : Path.Combine(_paths.Root, path);
+}
diff --git a/StarterNG/Infrastructure/Settings/SettingsPaths.cs b/StarterNG/Infrastructure/Settings/SettingsPaths.cs
new file mode 100644
index 0000000..f8e62b1
--- /dev/null
+++ b/StarterNG/Infrastructure/Settings/SettingsPaths.cs
@@ -0,0 +1,54 @@
+using System.IO;
+using StarterNG.Application.Abstractions;
+
+namespace StarterNG.Infrastructure.Settings;
+
+///
+/// Where the simulator's eu07.ini lives: per user on each platform, with the
+/// installation's own copy as the template for a first run.
+///
+public sealed class SettingsPaths
+{
+ private const string ConfigFileName = "eu07.ini";
+ private const string VendorFolder = "MaSzyna";
+
+ private readonly IEnvironment _environment;
+ private readonly IGamePaths _paths;
+
+ public SettingsPaths(IEnvironment environment, IGamePaths paths)
+ {
+ _environment = environment;
+ _paths = paths;
+ }
+
+ /// The user's own settings file, which is what the starter writes.
+ public string UserConfigPath()
+ {
+ if (_environment.IsWindows)
+ {
+ string? appData = _environment.GetVariable("APPDATA");
+ if (!string.IsNullOrEmpty(appData))
+ return Path.Combine(appData, VendorFolder, ConfigFileName);
+ }
+ else if (_environment.IsMacOS)
+ {
+ string? home = _environment.GetVariable("HOME");
+ if (!string.IsNullOrEmpty(home))
+ return Path.Combine(home, "Library", "Application Support", VendorFolder, ConfigFileName);
+ }
+ else
+ {
+ string? home = _environment.GetVariable("HOME");
+ if (!string.IsNullOrEmpty(home))
+ return Path.Combine(home, ".config", VendorFolder, ConfigFileName);
+ }
+
+ return Path.Combine(_environment.BaseDirectory, ConfigFileName);
+ }
+
+ /// The ini shipped with the installation, read when the user has none.
+ public string InstallationConfigPath() => _paths.FromRoot(ConfigFileName);
+
+ public string UserConfigDirectory() =>
+ Path.GetDirectoryName(UserConfigPath()) ?? _environment.BaseDirectory;
+}
diff --git a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs
new file mode 100644
index 0000000..2dd05e7
--- /dev/null
+++ b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs
@@ -0,0 +1,310 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using StarterNG.Classes;
+using StarterNG.Domain.Settings;
+
+namespace StarterNG.Infrastructure.Settings;
+
+///
+/// Maps onto the simulator's eu07.ini and back.
+///
+///
+/// Every key the starter understands is named exactly once, here. Reads clamp
+/// and default so a hand-edited ini cannot put the UI into an impossible state,
+/// and writes preserve the keys the starter does not manage - which is why a
+/// round trip through this class is worth testing on its own.
+///
+public sealed class SettingsSerializer
+{
+ public void Read(SimulatorSettings s, ConfigFile c)
+ {
+
+ s.LanguageWasSet = c.Has("lang");
+ s.Language = c.GetString("lang", "en").ToLowerInvariant() == "pl" ? "Polski" : "English";
+ s.Fullscreen = c.GetBool("fullscreen", false);
+ s.PauseWhenInactive = c.GetBool("inactivepause", true);
+ s.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))
+ {
+ s.InvertMouseHorizontal = mx < 0;
+ s.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))
+ {
+ s.InvertMouseVertical = my < 0;
+ s.MouseScaleYMagnitude = Math.Abs(my);
+ }
+
+ s.IgnoreGamepad = !c.GetBool("input.gamepad", true);
+ s.FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5);
+ s.FeedbackPort = c.GetInt("feedbackport", 888);
+ s.UartEnabled = c.Has("uart");
+ s.UartPort = c.GetString("uart", "");
+ s.UartTune = c.GetString("uarttune", "");
+ s.UartDebug = c.GetBool("uartdebug", false);
+ if (c.Has("uartfeature"))
+ {
+ var uartTokens = (c.GetRaw("uartfeature") ?? "").Split('|', StringSplitOptions.RemoveEmptyEntries);
+ var uf = new HashSet(uartTokens, StringComparer.OrdinalIgnoreCase);
+ s.UartMain = uf.Contains("main"); s.UartScnd = uf.Contains("scnd"); s.UartTrain = uf.Contains("train");
+ s.UartLocal = uf.Contains("local"); s.UartRadioVolume = uf.Contains("radiovolume"); s.UartRadioChannel = uf.Contains("radiochannel");
+ }
+ else
+ {
+ s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = true;
+ s.UartRadioVolume = s.UartRadioChannel = false;
+ }
+
+ s.SelectExeAutomatically = c.GetBool("starter.exe.auto", true);
+ s.ExecutablePath = c.GetString("starter.exe.path", "eu07.exe");
+ s.DebugMode = c.GetBool("debugmode", false);
+ s.VirtualShunting = c.GetBool("ai.trainman", true);
+ s.LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false);
+
+ s.RenderEngine = IndexOf(SimulatorSettings.RenderEngines, c.GetString("gfxrenderer", "full"), 0);
+ s.Width = c.GetInt("width", 1280);
+ s.Height = c.GetInt("height", 720);
+ s.BufferScalePercent = Clamp(c.GetInt("starter.bufferscale", 100), 50, 200);
+ s.MaxTextureSize = c.GetInt("maxtexturesize", 4096);
+ s.MaxCabTextureSize = c.GetInt("maxcabtexturesize", 4096);
+ s.TextureFiltering = c.GetInt("anisotropicfiltering", 8);
+ s.Multisampling = Clamp(c.GetInt("multisampling", 2), 0, 3);
+ s.DynamicLights = Clamp(c.GetInt("dynamiclights", 7), 0, 7);
+ s.DrawRangeFactor = c.GetDouble("gfx.drawrange.factor.max", 3.0);
+ s.VSync = c.GetBool("vsync", false);
+ s.Smoke = c.GetBool("gfx.smoke", true);
+ s.SmokeFidelity = Clamp(c.GetInt("gfx.smoke.fidelity", 1), 1, 4);
+ s.ChromaticAberration = c.GetBool("gfx.postfx.chromaticaberration.enabled", true);
+ s.MotionBlur = c.GetBool("gfx.postfx.motionblur.enabled", true);
+ s.ExtraEffects = c.GetBool("gfx.extraeffects", true);
+ s.EnvMap = c.GetBool("gfx.envmap.enabled", true);
+ s.UseVbo = c.GetBool("usevbo", true);
+ s.RenderShadows = c.GetBool("shadows", true);
+ s.ReflectionsFramerate = c.GetInt("gfx.reflections.framerate", 60);
+
+ var shadowTune = c.GetTokens("shadowtune");
+ if (shadowTune.Length >= 1) s.ShadowMapResolution = ParseInt(shadowTune[0], 2048);
+ if (shadowTune.Length >= 2) s.ShadowProjectionRange = ParseInt(shadowTune[1], 250);
+ if (shadowTune.Length >= 4) s.ShadowTuneExtra = new[] { shadowTune[2], shadowTune[3] };
+
+ s.CabShadowsRange = (int)Math.Round(c.GetDouble("gfx.shadows.cab.range", 0));
+ s.ShadowRankCutoff = c.GetInt("gfx.shadow.rank.cutoff", 3);
+ s.ReflectionsFidelity = Clamp(c.GetInt("gfx.reflections.fidelity", 0), 0, 2);
+ s.FieldOfView = Clamp((int)Math.Round(c.GetDouble("fieldofview", 45)), 15, 75);
+ s.PythonScreens = c.GetBool("python.enabled", true);
+ s.PythonThreadedUpload = c.GetBool("python.threadedupload", true);
+ s.FpsLimitEnabled = c.Has("fpslimit");
+ s.FpsLimit = Clamp(c.GetInt("fpslimit", 30), 1, 100);
+ s.ShadowAngleLimit = c.GetDouble("gfx.shadow.angle.min", 0.2);
+ s.FullscreenWindowed = c.GetBool("fullscreenwindowed", false);
+ s.RenderAngleVulkan = string.Equals(c.GetString("gfx.angleplatform", ""), "vulkan", StringComparison.OrdinalIgnoreCase);
+
+ s.SplineFidelity = Clamp(c.GetInt("splinefidelity", 1), 1, 4);
+ s.FullPhysics = c.GetBool("fullphysics", true);
+ s.EnableTraction = c.GetBool("enabletraction", true);
+ s.LiveTraction = c.GetBool("livetraction", true);
+ s.PhysicsLog = c.GetBool("physicslog", false);
+ int debugLog = c.GetInt("debuglog", 3);
+ s.DebugLog = (debugLog & 1) != 0;
+ s.DebugLogVisible = (debugLog & 2) != 0;
+ s.DebugLogExtraBits = debugLog & ~3;
+ s.MultipleLogs = c.GetBool("multiplelogs", false);
+ s.DisplaySimulation = !c.GetBool("gfx.skiprendering", false);
+ s.CrashDamage = c.GetBool("crashdamage", true);
+ s.Friction = c.GetDouble("friction", 1.0);
+ s.BrakeStep = c.GetDouble("brakestep", 1.0);
+ s.BrakeSpeed = c.GetDouble("brakespeed", 3.0);
+
+ s.SoundEnabled = c.GetBool("soundenabled", true);
+ s.Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100);
+ s.RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100);
+ s.VehiclesVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.vehicle", 1.0) * 100), 1, 100);
+ s.PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 1.0) * 100), 1, 100);
+ s.AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 1, 100);
+ s.SkipPipeline = c.GetBool("gfx.skippipeline", false);
+ s.PythonScreenUpdateRate = c.Has("python.updatetime")
+ ? Clamp(c.GetInt("python.updatetime", 200), 0, 10000)
+ : MapOldPyScreenPriority(c.GetString("pyscreenrendererpriority", "normal"));
+
+ s.CompressTextures = c.GetBool("compresstex", true);
+ s.ScaleSpeculars = c.GetBool("scalespeculars", true);
+ s.UseGLES = c.GetBool("gfx.usegles", false);
+ s.ShaderGamma = c.GetBool("gfx.shadergamma", false);
+ s.ScreenMipmaps = c.GetBool("python.mipmaps", true);
+ s.ExtendedModelConversion = c.GetInt("convertmodels", 0) != 0;
+ s.GfxResourceMove = c.GetBool("gfx.resource.move", false);
+ s.GfxResourceSweep = c.GetBool("gfx.resource.sweep", true);
+ s.TrainPhysicsThreads = Clamp(c.GetInt("async.trainThreads", 0), 0, Environment.ProcessorCount);
+ s.IgnoreIrrelevantTrains = c.GetBool("starter.ignoreirrelevant", false);
+
+ s.AutoCloseStarter = c.GetBool("starter.autoclose", false);
+ s.LargeThumbnails = c.GetBool("starter.largethumbnails", false);
+ s.AutoExpandSceneryTree = c.GetBool("starter.expandtree", false);
+ s.BatteryDefault = (BatteryDefault)Clamp(c.GetInt("starter.batterydefault", 0), 0, 2);
+ s.ShowAiVehicles = c.GetBool("starter.show.ai", true);
+ s.DrivableOnly = c.GetBool("starter.drivableonly", true);
+ s.ShowArchivalSceneries = c.GetBool("starter.show.archival", true);
+ s.HideArchivalVehicles = c.GetBool("starter.hide.archivalvehicles", true);
+ s.LastScenery = c.GetString("starter.last.scenery", "");
+ s.WindowMaximized = c.GetBool("starter.window.maximized", false);
+ }
+
+ private static readonly string[] ObsoleteKeys =
+ {
+ "gfx.postfx.tonemapping",
+ };
+
+ public void Write(SimulatorSettings s, ConfigFile c)
+ {
+
+ foreach (string key in ObsoleteKeys)
+ c.Remove(key);
+
+ c.Set("lang", s.Language == "Polski" ? "pl" : "en");
+ c.SetBool("fullscreen", s.Fullscreen);
+ c.SetBool("inactivepause", s.PauseWhenInactive);
+ c.SetBool("pause", s.PauseOnStart);
+
+ double mx = s.CursorSensitivity * (s.InvertMouseHorizontal ? -1 : 1);
+ double my = s.MouseScaleYMagnitude * (s.InvertMouseVertical ? -1 : 1);
+ c.Set("mousescale",
+ $"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " +
+ $"{my.ToString("0.###", CultureInfo.InvariantCulture)}");
+
+ c.SetBool("input.gamepad", !s.IgnoreGamepad);
+ c.SetInt("feedbackmode", s.FeedbackMode);
+ c.SetInt("feedbackport", s.FeedbackPort);
+ if (s.UartEnabled && !string.IsNullOrWhiteSpace(s.UartPort)) c.Set("uart", s.UartPort); else c.Remove("uart");
+ c.Set("uarttune", s.UartTune);
+ c.SetBool("uartdebug", s.UartDebug);
+ var sb = new StringBuilder();
+ if (s.UartMain) sb.Append("main|");
+ if (s.UartScnd) sb.Append("scnd|");
+ if (s.UartTrain) sb.Append("train|");
+ if (s.UartLocal) sb.Append("local|");
+ if (s.UartRadioVolume) sb.Append("radiovolume|");
+ if (s.UartRadioChannel) sb.Append("radiochannel|");
+ c.Set("uartfeature", sb.ToString());
+
+ c.SetBool("starter.exe.auto", s.SelectExeAutomatically);
+ c.Set("starter.exe.path", s.ExecutablePath);
+ c.SetBool("debugmode", s.DebugMode);
+ c.SetBool("ai.trainman", s.VirtualShunting);
+ c.SetBool("starter.logmissingvehicles", s.LogMissingVehicleFiles);
+
+ c.Set("gfxrenderer", SimulatorSettings.RenderEngines[Clamp(s.RenderEngine, 0, SimulatorSettings.RenderEngines.Length - 1)]);
+ c.SetInt("width", s.Width);
+ c.SetInt("height", s.Height);
+ c.SetInt("starter.bufferscale", s.BufferScalePercent);
+ int fbw = (int)Math.Round(s.Width * s.BufferScalePercent / 100.0);
+ int fbh = (int)Math.Round(s.Height * s.BufferScalePercent / 100.0);
+ c.SetInt("gfx.framebuffer.width", fbw);
+ c.SetInt("gfx.framebuffer.height", fbh);
+ c.SetInt("maxtexturesize", s.MaxTextureSize);
+ c.SetInt("maxcabtexturesize", s.MaxCabTextureSize);
+ c.SetInt("anisotropicfiltering", s.TextureFiltering);
+ c.SetInt("multisampling", s.Multisampling);
+ c.SetInt("dynamiclights", s.DynamicLights);
+ c.SetDouble("gfx.drawrange.factor.max", s.DrawRangeFactor);
+ c.SetBool("vsync", s.VSync);
+ c.SetBool("gfx.smoke", s.Smoke);
+ c.SetInt("gfx.smoke.fidelity", s.SmokeFidelity);
+ c.SetBool("gfx.postfx.chromaticaberration.enabled", s.ChromaticAberration);
+ c.SetBool("gfx.postfx.motionblur.enabled", s.MotionBlur);
+ c.SetBool("gfx.extraeffects", s.ExtraEffects);
+ c.SetBool("gfx.envmap.enabled", s.EnvMap);
+ c.SetBool("usevbo", s.UseVbo);
+ c.SetBool("shadows", s.RenderShadows);
+ c.SetBool("gfx.shadowmap.enabled", s.RenderShadows);
+ c.SetInt("gfx.reflections.framerate", s.ReflectionsFramerate);
+ c.Set("shadowtune",
+ $"{s.ShadowMapResolution} {s.ShadowProjectionRange} " +
+ $"{s.ShadowTuneExtra[0]} {s.ShadowTuneExtra[1]}");
+ c.SetInt("gfx.shadows.cab.range", s.CabShadowsRange);
+ c.SetInt("gfx.shadow.rank.cutoff", s.ShadowRankCutoff);
+ c.SetInt("gfx.reflections.fidelity", s.ReflectionsFidelity);
+ c.SetInt("fieldofview", s.FieldOfView);
+ c.SetBool("python.enabled", s.PythonScreens);
+ c.SetBool("python.threadedupload", s.PythonThreadedUpload);
+ if (s.FpsLimitEnabled) c.SetInt("fpslimit", s.FpsLimit); else c.Remove("fpslimit");
+ c.SetDouble("gfx.shadow.angle.min", s.ShadowAngleLimit);
+ c.SetBool("fullscreenwindowed", s.FullscreenWindowed);
+ if (s.RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); else c.Remove("gfx.angleplatform");
+
+ c.SetInt("splinefidelity", s.SplineFidelity);
+ c.SetBool("fullphysics", s.FullPhysics);
+ c.SetBool("enabletraction", s.EnableTraction);
+ c.SetBool("livetraction", s.LiveTraction);
+ c.SetBool("physicslog", s.PhysicsLog);
+ c.SetInt("debuglog",
+ (s.DebugLog ? 1 : 0) | (s.DebugLogVisible ? 2 : 0) | s.DebugLogExtraBits);
+ c.SetBool("multiplelogs", s.MultipleLogs);
+ c.SetBool("gfx.skiprendering", !s.DisplaySimulation);
+ c.SetBool("crashdamage", s.CrashDamage);
+ c.SetDouble("friction", s.Friction);
+ c.SetDouble("brakestep", s.BrakeStep);
+ c.SetDouble("brakespeed", s.BrakeSpeed);
+
+ c.SetBool("soundenabled", s.SoundEnabled);
+ c.SetDouble("sound.volume", s.Volume / 50.0);
+ c.SetDouble("sound.volume.radio", s.RadioVolume / 100.0);
+ c.SetDouble("sound.volume.vehicle", s.VehiclesVolume / 100.0);
+ c.SetDouble("sound.volume.positional", s.PositionalVolume / 100.0);
+ c.SetDouble("sound.volume.ambient", s.AmbientVolume / 100.0);
+ c.SetBool("gfx.skippipeline", s.SkipPipeline);
+ c.SetInt("python.updatetime", s.PythonScreenUpdateRate);
+
+ c.SetBool("compresstex", s.CompressTextures);
+ c.SetBool("scalespeculars", s.ScaleSpeculars);
+ c.SetBool("gfx.usegles", s.UseGLES);
+ c.SetBool("gfx.shadergamma", s.ShaderGamma);
+ c.SetBool("python.mipmaps", s.ScreenMipmaps);
+ c.Set("convertmodels", s.ExtendedModelConversion ? "143" : "0");
+ c.SetBool("gfx.resource.move", s.GfxResourceMove);
+ c.SetBool("gfx.resource.sweep", s.GfxResourceSweep);
+ c.SetInt("async.trainThreads", s.TrainPhysicsThreads);
+ c.SetBool("starter.ignoreirrelevant", s.IgnoreIrrelevantTrains);
+
+ c.SetBool("starter.autoclose", s.AutoCloseStarter);
+ c.SetBool("starter.largethumbnails", s.LargeThumbnails);
+ c.SetBool("starter.expandtree", s.AutoExpandSceneryTree);
+ c.SetInt("starter.batterydefault", (int)s.BatteryDefault);
+ c.SetBool("starter.show.ai", s.ShowAiVehicles);
+ c.SetBool("starter.drivableonly", s.DrivableOnly);
+ c.SetBool("starter.show.archival", s.ShowArchivalSceneries);
+ c.SetBool("starter.hide.archivalvehicles", s.HideArchivalVehicles);
+ if (!string.IsNullOrWhiteSpace(s.LastScenery))
+ c.Set("starter.last.scenery", s.LastScenery);
+ else
+ c.Remove("starter.last.scenery");
+ c.SetBool("starter.window.maximized", s.WindowMaximized);
+ }
+
+ 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;
+ }
+
+ private static int MapOldPyScreenPriority(string priority) =>
+ priority.ToLowerInvariant() switch
+ {
+ "lower" => 400,
+ "lowest" => 800,
+ "idle" => 1600,
+ "off" => 0,
+ _ => 200
+ };
+}
diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs
index 02232c6..4d389d9 100644
--- a/StarterNG/MainWindow.axaml.cs
+++ b/StarterNG/MainWindow.axaml.cs
@@ -16,9 +16,9 @@ using StarterNG.Domain;
using StarterNG.Infrastructure;
using StarterNG.Services;
using StarterNG.Views;
-using SettingsModel = StarterNG.Classes.Settings;
using StarterNG.Application;
using StarterNG.Domain.Sceneries;
+using StarterNG.Domain.Settings;
namespace StarterNG;
@@ -37,13 +37,13 @@ public partial class MainWindow : Window
ExtendClientAreaToDecorationsHint = true;
ExtendClientAreaTitleBarHeightHint = TitleBarHeight;
- if (SettingsModel.Instance.WindowMaximized)
+ if (AppServices.Current.Settings.WindowMaximized)
WindowState = WindowState.Maximized;
PropertyChanged += (_, e) =>
{
if (e.Property == WindowStateProperty && WindowState != WindowState.Minimized)
- SettingsModel.Instance.WindowMaximized = WindowState == WindowState.Maximized;
+ AppServices.Current.Settings.WindowMaximized = WindowState == WindowState.Maximized;
};
if (OperatingSystem.IsWindows())
@@ -429,7 +429,7 @@ public partial class MainWindow : Window
if (trainset.Vehicles.Count > 0)
{
- trainset.Velocity = SettingsModel.Instance.BatteryDefault switch
+ trainset.Velocity = AppServices.Current.Settings.BatteryDefault switch
{
BatteryDefault.AlwaysOff => 0f,
BatteryDefault.AlwaysOn => MathF.Abs(trainset.OriginalVelocity) < 0.01f ? 0.1f : trainset.OriginalVelocity,
@@ -452,7 +452,7 @@ public partial class MainWindow : Window
string exportPath = Path.Combine(dir, exportName);
try
{
- File.WriteAllText(exportPath, scenery.BuildExportContent(SettingsModel.Instance.IgnoreIrrelevantTrains, AppServices.Current.Random), Encoding.GetEncoding(1250));
+ File.WriteAllText(exportPath, scenery.BuildExportContent(AppServices.Current.Settings.IgnoreIrrelevantTrains, AppServices.Current.Random), Encoding.GetEncoding(1250));
}
catch (Exception ex)
{
@@ -461,9 +461,9 @@ public partial class MainWindow : Window
}
if (saveSettings)
- SettingsModel.Instance.CaptureAndSave();
+ AppServices.Current.SettingsStore.CaptureAndSave();
- string exe = Path.GetFullPath(SettingsModel.Instance.ResolveExecutable(out var exeProblem));
+ string exe = Path.GetFullPath(AppServices.Current.SettingsStore.ResolveExecutable(out var exeProblem));
if (exeProblem != ExeProblem.None)
{
await ShowExeProblem(exeProblem, exe);
@@ -488,7 +488,7 @@ public partial class MainWindow : Window
return;
}
- if (SettingsModel.Instance.AutoCloseStarter)
+ if (AppServices.Current.Settings.AutoCloseStarter)
{
Close();
return;
@@ -525,7 +525,7 @@ public partial class MainWindow : Window
{
if (sim is null)
{
- string name = Path.GetFileNameWithoutExtension(SettingsModel.Instance.ResolveExecutable());
+ string name = Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable());
sim = Process.GetProcessesByName(name).FirstOrDefault();
}
@@ -555,7 +555,7 @@ public partial class MainWindow : Window
{
if ((DateTime.UtcNow - _openedUtc).TotalSeconds < 3)
return;
- if (!SettingsModel.Instance.IsConfigNewerOnDisk())
+ if (!AppServices.Current.SettingsStore.IsConfigNewerOnDisk())
return;
var result = await MessageBox.Show(
@@ -565,11 +565,11 @@ public partial class MainWindow : Window
MessageBoxButtons.YesNo);
if (result)
{
- SettingsModel.Instance.Load();
+ AppServices.Current.SettingsStore.Load();
SettingsView?.ReloadFromSettings();
}
else
- SettingsModel.Instance.TouchConfigAge();
+ AppServices.Current.SettingsStore.TouchConfigAge();
}
private void RestoreFromSimulator()
diff --git a/StarterNG/Views/AdvancedSettingsWindow.cs b/StarterNG/Views/AdvancedSettingsWindow.cs
index f17f5f3..f54d609 100644
--- a/StarterNG/Views/AdvancedSettingsWindow.cs
+++ b/StarterNG/Views/AdvancedSettingsWindow.cs
@@ -4,7 +4,7 @@ using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
using StarterNG.Infrastructure;
-using SettingsModel = StarterNG.Classes.Settings;
+using StarterNG.Application;
namespace StarterNG.Views;
@@ -156,7 +156,7 @@ public sealed class AdvancedSettingsWindow : Window
private void Load()
{
- var s = SettingsModel.Instance;
+ var s = AppServices.Current.Settings;
_compressTex.IsChecked = s.CompressTextures;
_mipmaps.IsChecked = s.ScreenMipmaps;
_scaleSpeculars.IsChecked = s.ScaleSpeculars;
@@ -172,7 +172,7 @@ public sealed class AdvancedSettingsWindow : Window
private void Store()
{
- var s = SettingsModel.Instance;
+ var s = AppServices.Current.Settings;
s.CompressTextures = _compressTex.IsChecked == true;
s.ScreenMipmaps = _mipmaps.IsChecked == true;
s.ScaleSpeculars = _scaleSpeculars.IsChecked == true;
diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs
index 85b75c0..2bf6957 100644
--- a/StarterNG/Views/Depot.axaml.cs
+++ b/StarterNG/Views/Depot.axaml.cs
@@ -91,7 +91,7 @@ public partial class Depot : UserControl
Dispatcher.UIThread.Post(() =>
{
- hideArchivalCheck.IsChecked = StarterNG.Classes.Settings.Instance.HideArchivalVehicles;
+ hideArchivalCheck.IsChecked = AppServices.Current.Settings.HideArchivalVehicles;
_browser.InitClassComboTemplates();
_browser.PopulateCategoryCombo();
PopulateSceneryConsists();
@@ -232,8 +232,8 @@ public partial class Depot : UserControl
private void ConsistFilter_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_suppress) return;
- StarterNG.Classes.Settings.Instance.ShowAiVehicles = depotShowAiCheck.IsChecked == true;
- StarterNG.Classes.Settings.Instance.DrivableOnly = depotDrivableOnlyCheck.IsChecked == true;
+ AppServices.Current.Settings.ShowAiVehicles = depotShowAiCheck.IsChecked == true;
+ AppServices.Current.Settings.DrivableOnly = depotDrivableOnlyCheck.IsChecked == true;
PopulateSceneryConsists();
}
@@ -242,8 +242,8 @@ public partial class Depot : UserControl
_suppress = true;
sceneryConsistList.Items.Clear();
- depotShowAiCheck.IsChecked = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
- depotDrivableOnlyCheck.IsChecked = StarterNG.Classes.Settings.Instance.DrivableOnly;
+ depotShowAiCheck.IsChecked = AppServices.Current.Settings.ShowAiVehicles;
+ depotDrivableOnlyCheck.IsChecked = AppServices.Current.Settings.DrivableOnly;
_listScenery = AppServices.Current.State.CurrentScenery ?? _sceneries.FirstOrDefault();
sceneryConsistsTab.Header = App.Loc["SceneryConsists"];
@@ -252,8 +252,8 @@ public partial class Depot : UserControl
if (_listScenery != null)
{
- bool showAi = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
- bool drivableOnly = StarterNG.Classes.Settings.Instance.DrivableOnly;
+ bool showAi = AppServices.Current.Settings.ShowAiVehicles;
+ bool drivableOnly = AppServices.Current.Settings.DrivableOnly;
foreach (var trainset in _listScenery.Trainsets)
{
diff --git a/StarterNG/Views/Depot/VehicleBrowser.cs b/StarterNG/Views/Depot/VehicleBrowser.cs
index 9b78381..2a59880 100644
--- a/StarterNG/Views/Depot/VehicleBrowser.cs
+++ b/StarterNG/Views/Depot/VehicleBrowser.cs
@@ -16,6 +16,7 @@ using StarterNG.Classes;
using StarterNG.Domain;
using StarterNG.Domain.Sceneries;
using StarterNG.Domain.Vehicles;
+using StarterNG.Application;
namespace StarterNG.Views;
@@ -35,7 +36,7 @@ public sealed class VehicleBrowser
public void SyncHideArchival()
{
- bool hideArchival = Classes.Settings.Instance.HideArchivalVehicles;
+ bool hideArchival = AppServices.Current.Settings.HideArchivalVehicles;
if (hideArchivalCheck.IsChecked == hideArchival)
return;
@@ -246,7 +247,7 @@ public sealed class VehicleBrowser
_db.Textures
.Where(t => category == null || category(VehicleInfo.CategoryOf(t)))
.Where(t => !_db.IsSetFollower(t))
- .Where(t => !StarterNG.Classes.Settings.Instance.HideArchivalVehicles || !t.ResolvedArchived)
+ .Where(t => !AppServices.Current.Settings.HideArchivalVehicles || !t.ResolvedArchived)
.Select(VehicleInfo.ClassOf)
.Where(s => !string.IsNullOrEmpty(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
@@ -469,7 +470,7 @@ public sealed class VehicleBrowser
if (_db.IsSetFollower(t))
return false;
- if (StarterNG.Classes.Settings.Instance.HideArchivalVehicles && t.ResolvedArchived)
+ if (AppServices.Current.Settings.HideArchivalVehicles && t.ResolvedArchived)
return false;
if (hasSearch && !Matches(t, search))
@@ -515,8 +516,8 @@ public sealed class VehicleBrowser
public void HideArchivalCheck_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_suppress) return;
- StarterNG.Classes.Settings.Instance.HideArchivalVehicles = hideArchivalCheck.IsChecked ?? true;
- StarterNG.Classes.Settings.Instance.Save();
+ AppServices.Current.Settings.HideArchivalVehicles = hideArchivalCheck.IsChecked ?? true;
+ AppServices.Current.SettingsStore.Save();
Rebuild();
}
diff --git a/StarterNG/Views/Depot/VehicleCardStyle.cs b/StarterNG/Views/Depot/VehicleCardStyle.cs
index 166809c..348c08f 100644
--- a/StarterNG/Views/Depot/VehicleCardStyle.cs
+++ b/StarterNG/Views/Depot/VehicleCardStyle.cs
@@ -1,4 +1,5 @@
using Avalonia.Media;
+using StarterNG.Application;
namespace StarterNG.Views;
@@ -14,5 +15,5 @@ public static class VehicleCardStyle
public static readonly IBrush Placeholder = new SolidColorBrush(Color.Parse("#22808080"));
public static int ThumbHeight =>
- Classes.Settings.Instance.LargeThumbnails ? 68 : 34;
+ AppServices.Current.Settings.LargeThumbnails ? 68 : 34;
}
diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs
index 8b4df2c..de3e3a7 100644
--- a/StarterNG/Views/Scenarios.axaml.cs
+++ b/StarterNG/Views/Scenarios.axaml.cs
@@ -41,7 +41,7 @@ public partial class Scenarios : UserControl
Sceneries = AppServices.Current.Library.Sceneries;
_loadingFilters = true;
- archivalSwitch.IsChecked = !StarterNG.Classes.Settings.Instance.ShowArchivalSceneries;
+ archivalSwitch.IsChecked = !AppServices.Current.Settings.ShowArchivalSceneries;
_loadingFilters = false;
BuildSceneryTree();
@@ -75,7 +75,7 @@ public partial class Scenarios : UserControl
if (scenery.Archival && !showArchival)
continue;
- bool expand = StarterNG.Classes.Settings.Instance.AutoExpandSceneryTree;
+ bool expand = AppServices.Current.Settings.AutoExpandSceneryTree;
AppServices.Current.SceneryTexts.LoadFor(scenery, App.Loc.CurrentLangCode);
string label = scenery.DisplayName;
@@ -123,7 +123,7 @@ public partial class Scenarios : UserControl
private void RestoreLastScenery()
{
- string last = StarterNG.Classes.Settings.Instance.LastScenery;
+ string last = AppServices.Current.Settings.LastScenery;
string want = Path.GetFileNameWithoutExtension(last);
TreeViewItem? MatchLeaf(TreeViewItem node)
@@ -193,10 +193,10 @@ public partial class Scenarios : UserControl
{
string name = scenery.DisplayName;
- if (string.Equals(StarterNG.Classes.Settings.Instance.LastScenery, name, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(AppServices.Current.Settings.LastScenery, name, StringComparison.OrdinalIgnoreCase))
return;
- StarterNG.Classes.Settings.Instance.LastScenery = name;
- StarterNG.Classes.Settings.Instance.Save();
+ AppServices.Current.Settings.LastScenery = name;
+ AppServices.Current.SettingsStore.Save();
}
private static string TrainsetListLabel(Trainset trainset) =>
@@ -215,8 +215,8 @@ public partial class Scenarios : UserControl
{
vehicleList.Items.Clear();
- bool showAi = StarterNG.Classes.Settings.Instance.ShowAiVehicles;
- bool drivableOnly = StarterNG.Classes.Settings.Instance.DrivableOnly;
+ bool showAi = AppServices.Current.Settings.ShowAiVehicles;
+ bool drivableOnly = AppServices.Current.Settings.DrivableOnly;
for (int i = 0; i < scn.Trainsets.Count; i++)
{
@@ -250,8 +250,8 @@ public partial class Scenarios : UserControl
{
if (_loadingFilters) return;
- StarterNG.Classes.Settings.Instance.ShowArchivalSceneries = archivalSwitch.IsChecked != true;
- StarterNG.Classes.Settings.Instance.Save();
+ AppServices.Current.Settings.ShowArchivalSceneries = archivalSwitch.IsChecked != true;
+ AppServices.Current.SettingsStore.Save();
BuildSceneryTree();
RestoreLastScenery();
@@ -405,7 +405,7 @@ public partial class Scenarios : UserControl
BuildSceneryTree();
if (!string.IsNullOrEmpty(selected))
{
- StarterNG.Classes.Settings.Instance.LastScenery =
+ AppServices.Current.Settings.LastScenery =
Path.GetFileNameWithoutExtension(selected);
RestoreLastScenery();
}
@@ -428,7 +428,7 @@ public partial class Scenarios : UserControl
BuildSceneryTree();
if (!string.IsNullOrEmpty(selected))
{
- StarterNG.Classes.Settings.Instance.LastScenery =
+ AppServices.Current.Settings.LastScenery =
Path.GetFileNameWithoutExtension(selected);
}
RestoreLastScenery();
@@ -895,7 +895,7 @@ public partial class Scenarios : UserControl
string miniName = db.MiniForSkin(train.SkinFile) ?? train.SkinFile;
if (!AppServices.Current.MiniTextures.Has(miniName) && AppServices.Current.MiniTextures.Has(train.SkinFile))
miniName = train.SkinFile;
- int thumbH = StarterNG.Classes.Settings.Instance.LargeThumbnails ? 64 : 32;
+ int thumbH = AppServices.Current.Settings.LargeThumbnails ? 64 : 32;
var bmp = LoadMini(miniName, thumbH);
Control visual = bmp is not null
diff --git a/StarterNG/Views/Settings.axaml.cs b/StarterNG/Views/Settings.axaml.cs
index e26d6c5..b7a6ea2 100644
--- a/StarterNG/Views/Settings.axaml.cs
+++ b/StarterNG/Views/Settings.axaml.cs
@@ -19,10 +19,12 @@ using StarterNG.Infrastructure;
using StarterNG.Services;
using KeyBinding = StarterNG.Classes.KeyBinding;
+using StarterNG.Domain.Settings;
+using StarterNG.Application;
namespace StarterNG.Views;
-public partial class Settings : UserControl
+public partial class Settings : UserControl, ISettingsCapture
{
private bool _loading;
@@ -33,14 +35,14 @@ public partial class Settings : UserControl
public event Action? ThumbnailSizeChanged;
/// Thumbnail size the other tabs were last drawn at.
- private bool _drawnThumbs = StarterNG.Classes.Settings.Instance.LargeThumbnails;
+ private bool _drawnThumbs = AppServices.Current.Settings.LargeThumbnails;
public Settings()
{
InitializeComponent();
- SettingsPathText.Text = StarterNG.Classes.Settings.Instance.LoadedFrom;
- ToolTip.SetTip(SettingsPathText, StarterNG.Classes.Settings.Instance.LoadedFrom);
+ SettingsPathText.Text = AppServices.Current.SettingsStore.LoadedFrom;
+ ToolTip.SetTip(SettingsPathText, AppServices.Current.SettingsStore.LoadedFrom);
this.AttachedToVisualTree += (_, _) =>
{
@@ -60,7 +62,7 @@ public partial class Settings : UserControl
FeedbackCb.SelectionChanged += FeedbackCb_OnSelectionChanged;
FpsLimitEnableCb.IsCheckedChanged += (_, _) => FpsLimitSlider.IsEnabled = IsChecked(FpsLimitEnableCb);
- StarterNG.Classes.Settings.Instance.CaptureFromUi = ReadFromUi;
+ AppServices.Current.SettingsStore.RegisterCapture(this);
PopulateProfileCombo();
ApplyToUi();
@@ -111,7 +113,7 @@ public partial class Settings : UserControl
return;
string path = SettingsProfileStore.PathFor(name);
if (!File.Exists(path)) return;
- StarterNG.Classes.Settings.Instance.LoadFrom(path);
+ AppServices.Current.SettingsStore.LoadFrom(path);
ApplyToUi();
RedrawThumbnailsIfNeeded();
}
@@ -129,8 +131,8 @@ public partial class Settings : UserControl
void Commit()
{
if (string.IsNullOrWhiteSpace(nameBox.Text)) return;
- ReadFromUi();
- StarterNG.Classes.Settings.Instance.SaveTo(SettingsProfileStore.PathFor(nameBox.Text));
+ CaptureInto(AppServices.Current.Settings);
+ AppServices.Current.SettingsStore.SaveTo(SettingsProfileStore.PathFor(nameBox.Text));
PopulateProfileCombo();
int idx = FindComboIndexByContent(ProfileCb, nameBox.Text.Trim());
if (idx >= 0) ProfileCb.SelectedIndex = idx;
@@ -147,7 +149,7 @@ public partial class Settings : UserControl
string? current = (SelectExeCb.SelectedItem as ComboBoxItem)?.Content?.ToString();
SelectExeCb.Items.Clear();
SelectExeCb.Items.Add(new ComboBoxItem { Content = App.Loc["SelectEXEAuto"] });
- foreach (string exe in StarterNG.Classes.Settings.ListCandidateExecutables())
+ foreach (string exe in AppServices.Current.Executables.ListCandidates())
SelectExeCb.Items.Add(new ComboBoxItem { Content = exe });
if (current is not null)
{
@@ -160,7 +162,7 @@ public partial class Settings : UserControl
private void ApplyToUi()
{
- var s = StarterNG.Classes.Settings.Instance;
+ var s = AppServices.Current.Settings;
_loading = true;
try
{
@@ -268,9 +270,12 @@ public partial class Settings : UserControl
UpdateQualityCaptions();
}
- private void ReadFromUi()
+ ///
+ /// Writes the pending edits on this screen into the settings, so a save from
+ /// anywhere in the application picks them up.
+ ///
+ public void CaptureInto(SimulatorSettings s)
{
- var s = StarterNG.Classes.Settings.Instance;
s.Language = (ChangeLanguageCb.SelectedItem as ComboBoxItem)?.Content?.ToString()
?? App.Loc.CurrentLanguage;
@@ -297,8 +302,8 @@ public partial class Settings : UserControl
s.BufferScalePercent = (int)bufferScale.Value;
s.MaxTextureSize = 1 << (int)textureResolutionSlider.Value;
s.MaxCabTextureSize = 1 << (int)cabTextureResolutionSlider.Value;
- s.TextureFiltering = StarterNG.Classes.Settings.AnisotropySteps[
- Clamp((int)TexFilteringSlider.Value - 1, 0, StarterNG.Classes.Settings.AnisotropySteps.Length - 1)];
+ s.TextureFiltering = SimulatorSettings.AnisotropySteps[
+ Clamp((int)TexFilteringSlider.Value - 1, 0, SimulatorSettings.AnisotropySteps.Length - 1)];
s.Multisampling = Clamp((int)MultisamplingSlider.Value - 1, 0, 3);
s.DynamicLights = (int)DynamicLightsSlider.Value;
s.DrawRangeFactor = RenderRangeSlider.Value;
@@ -363,8 +368,8 @@ public partial class Settings : UserControl
private void SaveButton_OnClick(object? sender, RoutedEventArgs e)
{
- ReadFromUi();
- StarterNG.Classes.Settings.Instance.Save();
+ CaptureInto(AppServices.Current.Settings);
+ AppServices.Current.SettingsStore.Save();
KeyboardConfig.Instance.Save();
RedrawThumbnailsIfNeeded();
_dirty = false;
@@ -375,7 +380,7 @@ public partial class Settings : UserControl
private void RedrawThumbnailsIfNeeded()
{
- bool large = StarterNG.Classes.Settings.Instance.LargeThumbnails;
+ bool large = AppServices.Current.Settings.LargeThumbnails;
if (large == _drawnThumbs)
return;
@@ -385,7 +390,7 @@ public partial class Settings : UserControl
private void ResetButton_OnClick(object? sender, RoutedEventArgs e)
{
- StarterNG.Classes.Settings.Instance.Load();
+ AppServices.Current.SettingsStore.Load();
ApplyToUi();
RedrawThumbnailsIfNeeded();
CancelCapture();
@@ -413,7 +418,7 @@ public partial class Settings : UserControl
ResolutionCb.SelectedItem = added;
}
- private void ReadResolution(Classes.Settings s)
+ private void ReadResolution(SimulatorSettings s)
{
var text = (ResolutionCb.SelectedItem as ComboBoxItem)?.Content?.ToString();
if (string.IsNullOrEmpty(text))
@@ -688,7 +693,7 @@ public partial class Settings : UserControl
private static int AnisotropyToSlider(int anisotropy)
{
- var steps = StarterNG.Classes.Settings.AnisotropySteps;
+ var steps = SimulatorSettings.AnisotropySteps;
for (int i = 0; i < steps.Length; i++)
if (steps[i] == anisotropy)
return i + 1;
diff --git a/StarterNG/Views/UartWindow.cs b/StarterNG/Views/UartWindow.cs
index d19f0ce..69d1904 100644
--- a/StarterNG/Views/UartWindow.cs
+++ b/StarterNG/Views/UartWindow.cs
@@ -2,7 +2,7 @@ using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Layout;
using Avalonia.Media;
-using SettingsModel = StarterNG.Classes.Settings;
+using StarterNG.Application;
namespace StarterNG.Views;
@@ -99,7 +99,7 @@ public sealed class UartWindow : Window
private void Load()
{
- var s = SettingsModel.Instance;
+ var s = AppServices.Current.Settings;
_enabled.IsChecked = s.UartEnabled;
_port.Text = s.UartPort;
_tune.Text = s.UartTune;
@@ -114,7 +114,7 @@ public sealed class UartWindow : Window
private void Store()
{
- var s = SettingsModel.Instance;
+ var s = AppServices.Current.Settings;
s.UartEnabled = _enabled.IsChecked == true;
s.UartPort = _port.Text ?? "";
s.UartTune = _tune.Text ?? "";