From 3c502f630d6db81e4df7f951d246470f7ae85266 Mon Sep 17 00:00:00 2001 From: maj00r Date: Fri, 4 Sep 2026 21:13:06 +0200 Subject: [PATCH] Split Settings into model, serializer, paths and store 698 lines did all of it at once. The mutable CaptureFromUi callback becomes an ISettingsCapture the settings view implements. --- StarterNG.Tests/ExecutableLocatorTests.cs | 82 ++ StarterNG.Tests/Fakes/FakeEnvironment.cs | 34 + StarterNG.Tests/Fakes/InMemoryFileSystem.cs | 11 + StarterNG.Tests/Fakes/TestInstallation.cs | 10 + StarterNG.Tests/InstallationCheckTests.cs | 4 +- StarterNG.Tests/SettingsStoreTests.cs | 131 ++++ StarterNG/App.axaml.cs | 14 +- .../Application/Abstractions/IEnvironment.cs | 20 + .../Application/Abstractions/IFileSystem.cs | 6 + StarterNG/Application/AppServices.cs | 31 +- StarterNG/Application/MissingVehicleLog.cs | 49 ++ StarterNG/Application/SettingsStore.cs | 191 +++++ StarterNG/Classes/Settings.cs | 699 ------------------ StarterNG/Classes/SettingsProfileStore.cs | 3 +- .../Domain/Settings/SimulatorSettings.cs | 161 ++++ .../Adapters/PhysicalFileSystem.cs | 10 + .../Adapters/SystemEnvironment.cs | 18 + StarterNG/Infrastructure/Diagnostics.cs | 3 +- StarterNG/Infrastructure/InstallationCheck.cs | 8 +- .../Settings/ExecutableLocator.cs | 166 +++++ .../Infrastructure/Settings/SettingsPaths.cs | 54 ++ .../Settings/SettingsSerializer.cs | 310 ++++++++ StarterNG/MainWindow.axaml.cs | 24 +- StarterNG/Views/AdvancedSettingsWindow.cs | 6 +- StarterNG/Views/Depot.axaml.cs | 14 +- StarterNG/Views/Depot/VehicleBrowser.cs | 11 +- StarterNG/Views/Depot/VehicleCardStyle.cs | 3 +- StarterNG/Views/Scenarios.axaml.cs | 26 +- StarterNG/Views/Settings.axaml.cs | 45 +- StarterNG/Views/UartWindow.cs | 6 +- 30 files changed, 1371 insertions(+), 779 deletions(-) create mode 100644 StarterNG.Tests/ExecutableLocatorTests.cs create mode 100644 StarterNG.Tests/Fakes/FakeEnvironment.cs create mode 100644 StarterNG.Tests/SettingsStoreTests.cs create mode 100644 StarterNG/Application/Abstractions/IEnvironment.cs create mode 100644 StarterNG/Application/MissingVehicleLog.cs create mode 100644 StarterNG/Application/SettingsStore.cs delete mode 100644 StarterNG/Classes/Settings.cs create mode 100644 StarterNG/Domain/Settings/SimulatorSettings.cs create mode 100644 StarterNG/Infrastructure/Adapters/SystemEnvironment.cs create mode 100644 StarterNG/Infrastructure/Settings/ExecutableLocator.cs create mode 100644 StarterNG/Infrastructure/Settings/SettingsPaths.cs create mode 100644 StarterNG/Infrastructure/Settings/SettingsSerializer.cs 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 ?? "";