From 8d6b4805c873e9ff1ce4379a60706cd60c72f847 Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Tue, 27 Oct 2026 10:44:48 +0100 Subject: [PATCH 01/37] Add graphics presets, eu07.ini import/export and the new simulator settings --- StarterNG.Tests/GraphicsSettingsTests.cs | 405 +++++++++++++++ StarterNG/Application/AppServices.cs | 2 +- StarterNG/Application/SettingsStore.cs | 62 +++ StarterNG/Classes/ConfigFile.cs | 22 + StarterNG/Domain/Settings/GraphicsPresets.cs | 151 ++++++ StarterNG/Domain/Settings/GraphicsRules.cs | 105 ++++ .../Domain/Settings/SimulatorSettings.cs | 41 +- .../Settings/ExecutableLocator.cs | 31 ++ .../Settings/SettingsSerializer.cs | 226 ++++++++- StarterNG/Services/LocalizationService.cs | 35 +- StarterNG/Styles/Settings.axaml | 15 + StarterNG/Views/AdvancedSettingsWindow.cs | 6 +- StarterNG/Views/Settings.Graphics.cs | 458 +++++++++++++++++ StarterNG/Views/Settings.axaml | 479 +++++++++++------- StarterNG/Views/Settings.axaml.cs | 121 +---- StarterNG/startercfg/lang/cz.xml | 13 - StarterNG/startercfg/lang/en.xml | 123 ++++- StarterNG/startercfg/lang/hu.xml | 13 - StarterNG/startercfg/lang/pl.xml | 123 ++++- StarterNG/startercfg/lang/ru.xml | 13 - 20 files changed, 2034 insertions(+), 410 deletions(-) create mode 100644 StarterNG.Tests/GraphicsSettingsTests.cs create mode 100644 StarterNG/Domain/Settings/GraphicsPresets.cs create mode 100644 StarterNG/Domain/Settings/GraphicsRules.cs create mode 100644 StarterNG/Views/Settings.Graphics.cs diff --git a/StarterNG.Tests/GraphicsSettingsTests.cs b/StarterNG.Tests/GraphicsSettingsTests.cs new file mode 100644 index 0000000..98f496d --- /dev/null +++ b/StarterNG.Tests/GraphicsSettingsTests.cs @@ -0,0 +1,405 @@ +using StarterNG.Classes; +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure.Settings; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class GraphicsSettingsTests +{ + private static readonly string UserIni = Path.Combine("/home/kolejarz", ".config", "MaSzyna", "eu07.ini"); + + private static TestInstallation WithIni(string text) + { + var installation = new TestInstallation(new InMemoryFileSystem().WithFile(UserIni, text)); + installation.Environment.With("HOME", "/home/kolejarz"); + installation.SettingsStore.Load(); + return installation; + } + + private static SimulatorSettings Read(string text, bool dlssPlatform = true) + { + var s = new SimulatorSettings(); + new SettingsSerializer(dlssPlatform).Read(s, ConfigFile.Parse(text)); + return s; + } + + private static string Write(SimulatorSettings s, string text = "", bool dlssPlatform = true) + { + var c = ConfigFile.Parse(text); + new SettingsSerializer(dlssPlatform).Write(s, c); + return c.ToText(); + } + + private static string[] Lines(string text) => + text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None); + + private static string? Line(string text, string key) => + Lines(text).FirstOrDefault(l => l == key || l.StartsWith(key + " ", StringComparison.Ordinal)); + + [Theory] + [InlineData("full", GraphicsBackend.OpenGL)] + [InlineData("default", GraphicsBackend.OpenGL)] + [InlineData("gl46", GraphicsBackend.OpenGL)] + [InlineData("legacy", GraphicsBackend.OpenGL)] + [InlineData("experimental", GraphicsBackend.OpenGL)] + [InlineData("vulkan", GraphicsBackend.Vulkan)] + [InlineData("vulkan14", GraphicsBackend.Vulkan)] + public void The_renderer_is_read_with_the_aliases_the_simulator_takes(string value, GraphicsBackend expected) + { + Assert.Equal(expected, Read($"gfxrenderer {value}\n").Backend); + } + + [Fact] + public void The_renderer_is_written_as_a_name_every_simulator_reads() + { + Assert.Equal("gfxrenderer full", Line(Write(Read("gfxrenderer legacy\n")), "gfxrenderer")); + Assert.Equal("gfxrenderer vulkan", Line(Write(Read("gfxrenderer vulkan14\n")), "gfxrenderer")); + } + + [Fact] + public void The_new_graphics_keys_are_read() + { + var s = Read("gfxrenderer vulkan\n" + + "gfx.antialiasing dlss\ngfx.dlss.preset K\ngfx.framegeneration x3\ngfx.reflex boost\n" + + "gfx.raytracing yes\ngfx.raytracing.range 800\ngfx.ssr high\ngfx.postfx.ao ssao\ngfx.postfx.ao.quality 4 6\n" + + "gfx.lights.shadows low\ngfx.contactshadows 3\ngfx.fog analytic\ngfx.fog.quality ultra\n" + + "gfx.clouds ultra\ngfx.clouds.shadows no\ngfx.grass no\ngfx.grass.distance 120\ngfx.grass.density 1.5\n" + + "gfx.buffer.percent 0.67\ngfx.vulkan.device RTX\n"); + + Assert.Equal(GraphicsBackend.Vulkan, s.Backend); + Assert.Equal(AntiAliasingMode.Dlss, s.AntiAliasing); + Assert.Equal("k", s.DlssPreset); + Assert.Equal(3, s.FrameGeneration); + Assert.Equal(2, s.Reflex); + Assert.True(s.RayTracing); + Assert.Equal(800, s.RayTracingRange); + Assert.Equal(3, s.Ssr); + Assert.Equal(1, s.AmbientOcclusion); + Assert.Equal((4, 6), (s.AoSlices, s.AoSteps)); + Assert.Equal(1, s.LightShadows); + Assert.Equal(3, s.ContactShadows); + Assert.False(s.VolumetricFog); + Assert.Equal(4, s.FogQuality); + Assert.Equal(4, s.Clouds); + Assert.False(s.CloudShadows); + Assert.False(s.Grass); + Assert.Equal(120, s.GrassDistance); + Assert.Equal(1.5, s.GrassDensity); + Assert.Equal(67, s.BufferScalePercent); + Assert.Equal("RTX", s.VulkanDevice); + } + + [Fact] + public void Missing_keys_take_the_simulators_defaults() + { + var s = Read(""); + + Assert.Equal(GraphicsBackend.OpenGL, s.Backend); + Assert.Equal(AntiAliasingMode.Msaa4, s.AntiAliasing); + Assert.Equal(2, s.Ssr); + Assert.Equal(2, s.AmbientOcclusion); + Assert.Equal(2, s.LightShadows); + Assert.Equal(0, s.ContactShadows); + Assert.True(s.VolumetricFog); + Assert.Equal(2, s.FogQuality); + Assert.Equal(2, s.Clouds); + Assert.False(s.RayTracing); + Assert.Equal(1, s.FrameGeneration); + } + + [Fact] + public void The_tokens_written_are_the_ones_the_simulator_parses() + { + var s = Read("gfxrenderer vulkan\n"); + s.AntiAliasing = AntiAliasingMode.Dlaa; + s.FrameGeneration = 4; + s.Reflex = 1; + s.LightShadows = 3; + s.ContactShadows = 0; + s.Clouds = 4; + s.VolumetricFog = false; + s.FogQuality = 1; + s.AmbientOcclusion = 1; + s.Ssr = 2; + s.RayTracing = true; + + string text = Write(s); + + Assert.Equal("gfx.antialiasing dlaa", Line(text, "gfx.antialiasing")); + Assert.Equal("multisampling 0", Line(text, "multisampling")); + Assert.Equal("gfx.framegeneration x4", Line(text, "gfx.framegeneration")); + Assert.Equal("gfx.reflex on", Line(text, "gfx.reflex")); + Assert.Equal("gfx.lights.shadows high", Line(text, "gfx.lights.shadows")); + Assert.Equal("gfx.contactshadows no", Line(text, "gfx.contactshadows")); + Assert.Equal("gfx.clouds ultra", Line(text, "gfx.clouds")); + Assert.Equal("gfx.fog analytic", Line(text, "gfx.fog")); + Assert.Equal("gfx.fog.quality low", Line(text, "gfx.fog.quality")); + Assert.Equal("gfx.postfx.ao ssao", Line(text, "gfx.postfx.ao")); + Assert.Equal("gfx.ssr medium", Line(text, "gfx.ssr")); + Assert.Equal("gfx.raytracing yes", Line(text, "gfx.raytracing")); + Assert.Equal("gfx.buffer.percent 100", Line(text, "gfx.buffer.percent")); + } + + [Fact] + public void Msaa_is_written_to_both_keys_for_older_simulators() + { + var s = Read("multisampling 1\n"); + Assert.Equal(AntiAliasingMode.Msaa2, s.AntiAliasing); + + string text = Write(s); + + Assert.Equal("multisampling 1", Line(text, "multisampling")); + Assert.Equal("gfx.antialiasing msaax2", Line(text, "gfx.antialiasing")); + } + + [Fact] + public void Of_the_two_anti_aliasing_keys_the_later_one_wins_as_in_the_simulator() + { + Assert.Equal(AntiAliasingMode.Taa, Read("multisampling 2\ngfx.antialiasing taa\n").AntiAliasing); + Assert.Equal(AntiAliasingMode.Msaa4, Read("gfx.antialiasing taa\nmultisampling 2\n").AntiAliasing); + } + + [Fact] + public void Gfx_antialiasing_is_moved_below_multisampling_so_it_takes_effect() + { + var s = Read("gfx.antialiasing msaax4\nmultisampling 2\n"); + s.AntiAliasing = AntiAliasingMode.Taa; + + string text = Write(s, "gfx.antialiasing msaax4\nmultisampling 2\n"); + var lines = Lines(text).ToList(); + + Assert.True(lines.IndexOf("gfx.antialiasing taa") > lines.IndexOf("multisampling 0")); + Assert.Equal(AntiAliasingMode.Taa, Read(text).AntiAliasing); + } + + [Fact] + public void On_opengl_the_vulkan_only_features_are_written_off() + { + var s = Read("gfxrenderer full\ngfx.antialiasing dlss\ngfx.framegeneration x2\ngfx.reflex on\ngfx.raytracing yes\n"); + + Assert.Equal(AntiAliasingMode.Taa, s.AntiAliasing); + Assert.Equal(1, s.FrameGeneration); + Assert.Equal(0, s.Reflex); + Assert.False(s.RayTracing); + + string text = Write(s); + Assert.Equal("gfx.antialiasing taa", Line(text, "gfx.antialiasing")); + Assert.Equal("gfx.framegeneration off", Line(text, "gfx.framegeneration")); + Assert.Equal("gfx.reflex off", Line(text, "gfx.reflex")); + Assert.Equal("gfx.raytracing no", Line(text, "gfx.raytracing")); + } + + [Fact] + public void Frame_generation_needs_dlss_even_on_vulkan() + { + var s = Read("gfxrenderer vulkan\ngfx.antialiasing taa\ngfx.framegeneration x2\ngfx.reflex on\n"); + + Assert.Equal(1, s.FrameGeneration); + Assert.Equal(1, s.Reflex); + Assert.False(GraphicsRules.ReflexForced(s, true)); + } + + [Fact] + public void Frame_generation_turns_reflex_on() + { + var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlss\ngfx.framegeneration x2\n"); + + Assert.True(GraphicsRules.ReflexForced(s, true)); + } + + [Fact] + public void Without_a_dlss_platform_dlss_falls_back_to_taa() + { + var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlss\n", dlssPlatform: false); + + Assert.Equal(AntiAliasingMode.Taa, s.AntiAliasing); + } + + [Fact] + public void Dlaa_ignores_the_render_scale() + { + var s = Read("gfxrenderer vulkan\ngfx.antialiasing dlaa\n"); + Assert.False(GraphicsRules.BufferScaleUsed(s, true)); + + s.AntiAliasing = AntiAliasingMode.Dlss; + Assert.True(GraphicsRules.BufferScaleUsed(s, true)); + } + + [Fact] + public void Sizes_are_rounded_down_to_what_the_simulator_uses() + { + var s = Read("maxtexturesize 16384\nmaxcabtexturesize 3000\nshadowtune 3000 0 250 0\n"); + + Assert.Equal(8192, s.MaxTextureSize); + Assert.Equal(2048, s.MaxCabTextureSize); + Assert.Equal(2048, s.ShadowMapResolution); + } + + [Fact] + public void Keys_the_simulator_dropped_are_commented_out() + { + const string old = "usevbo yes\ngfx.angleplatform vulkan\ngfx.usegles no\ngfx.framebuffer.width 1920\ngfx.framebuffer.height 1080\n"; + + string text = Write(Read(old), old); + + foreach (string key in new[] { "usevbo", "gfx.angleplatform", "gfx.usegles", "gfx.framebuffer.width", "gfx.framebuffer.height" }) + { + Assert.Null(Line(text, key)); + Assert.Contains(Lines(text), l => l.StartsWith("// " + key, StringComparison.Ordinal)); + } + } + + [Fact] + public void The_render_scale_reads_the_old_starter_key_when_the_new_one_is_missing() + { + Assert.Equal(75, Read("starter.bufferscale 75\n").BufferScalePercent); + Assert.Equal(50, Read("starter.bufferscale 75\ngfx.buffer.percent 50\n").BufferScalePercent); + } + + [Theory] + [InlineData(GraphicsPreset.Lowest)] + [InlineData(GraphicsPreset.Low)] + [InlineData(GraphicsPreset.Medium)] + [InlineData(GraphicsPreset.High)] + [InlineData(GraphicsPreset.Ultra)] + public void A_preset_is_recognised_after_it_is_applied_and_survives_a_round_trip(GraphicsPreset preset) + { + foreach (var backend in new[] { GraphicsBackend.OpenGL, GraphicsBackend.Vulkan }) + { + var s = new SimulatorSettings { Backend = backend }; + GraphicsPresets.Apply(s, preset, true); + + Assert.Equal(preset, GraphicsPresets.Detect(s, true)); + Assert.Equal(preset, GraphicsPresets.Detect(Read(Write(s)), true)); + } + } + + [Fact] + public void A_change_after_a_preset_makes_the_settings_custom() + { + var s = new SimulatorSettings(); + GraphicsPresets.Apply(s, GraphicsPreset.High, true); + s.Clouds = 4; + + Assert.Equal(GraphicsPreset.Custom, GraphicsPresets.Detect(s, true)); + } + + [Fact] + public void Presets_leave_alone_what_is_not_a_matter_of_quality() + { + var s = new SimulatorSettings { Width = 2560, Height = 1440, BufferScalePercent = 67, VSync = true, FieldOfView = 60, MotionBlur = false }; + + GraphicsPresets.Apply(s, GraphicsPreset.Lowest, true); + + Assert.Equal((2560, 1440, 67, true, 60, false), (s.Width, s.Height, s.BufferScalePercent, s.VSync, s.FieldOfView, s.MotionBlur)); + } + + [Fact] + public void Ray_tracing_comes_with_ultra_on_vulkan_only() + { + var vulkan = new SimulatorSettings { Backend = GraphicsBackend.Vulkan }; + var opengl = new SimulatorSettings { Backend = GraphicsBackend.OpenGL }; + + GraphicsPresets.Apply(vulkan, GraphicsPreset.Ultra, true); + GraphicsPresets.Apply(opengl, GraphicsPreset.Ultra, true); + + Assert.True(vulkan.RayTracing); + Assert.False(opengl.RayTracing); + } + + [Fact] + public void A_preset_keeps_dlss_on_vulkan() + { + var s = new SimulatorSettings { Backend = GraphicsBackend.Vulkan, AntiAliasing = AntiAliasingMode.Dlss }; + + GraphicsPresets.Apply(s, GraphicsPreset.Ultra, true); + + Assert.Equal(AntiAliasingMode.Dlss, s.AntiAliasing); + Assert.Equal(GraphicsPreset.Ultra, GraphicsPresets.Detect(s, true)); + } + + [Fact] + public void An_import_takes_the_settings_but_keeps_this_installations_simulator_and_the_unmanaged_lines() + { + var installation = WithIni("starter.exe.auto no\nstarter.exe.path eu07_local.exe\ncalibratein 0 0 0 0\ngfx.clouds low\n"); + installation.Files.WithFile("/backup/eu07.ini", "starter.exe.auto no\nstarter.exe.path eu07_other.exe\ngfx.clouds ultra\ngfxrenderer vulkan\n"); + + Assert.True(installation.SettingsStore.ImportFrom("/backup/eu07.ini")); + var s = installation.SettingsStore.Settings; + Assert.Equal(4, s.Clouds); + Assert.Equal(GraphicsBackend.Vulkan, s.Backend); + Assert.Equal("eu07_local.exe", s.ExecutablePath); + + // Nothing is written until saved, and the save keeps the user's own lines. + Assert.Contains("gfx.clouds low", installation.Files.ReadAllText(UserIni)); + installation.SettingsStore.Save(); + string saved = installation.Files.ReadAllText(UserIni); + Assert.Contains("calibratein 0 0 0 0", saved); + Assert.Contains("gfx.clouds ultra", saved); + Assert.Contains("starter.exe.path eu07_local.exe", saved); + } + + [Fact] + public void An_import_of_a_missing_file_changes_nothing() + { + var installation = WithIni("gfx.clouds low\n"); + + Assert.False(installation.SettingsStore.ImportFrom("/nowhere/eu07.ini")); + Assert.Equal(1, installation.SettingsStore.Settings.Clouds); + } + + [Fact] + public void An_export_writes_a_complete_file_elsewhere_and_leaves_the_users_one_alone() + { + var installation = WithIni("calibratein 0 0 0 0\ngfx.clouds low\n"); + installation.SettingsStore.Settings.Clouds = 3; + + Assert.True(installation.SettingsStore.ExportTo("/export/eu07.ini")); + + string exported = installation.Files.ReadAllText("/export/eu07.ini"); + Assert.Contains("calibratein 0 0 0 0", exported); + Assert.Contains("gfx.clouds high", exported); + Assert.Contains("gfx.clouds low", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void The_dlss_libraries_are_looked_for_next_to_a_windows_build() + { + var files = new InMemoryFileSystem() + .WithDirectory(TestInstallation.Root) + .WithExecutable(TestInstallation.At("eu07.exe"), "MZ binary") + .WithFile(TestInstallation.At("nvngx_dlss.dll"), "dll"); + var installation = new TestInstallation(files); + + Assert.Equal((true, false), installation.Executables.DlssLibraries(TestInstallation.At("eu07.exe"))); + + files.WithFile(TestInstallation.At("nvngx_dlssg.dll"), "dll"); + Assert.Equal((true, true), installation.Executables.DlssLibraries(TestInstallation.At("eu07.exe"))); + } + + [Fact] + public void The_dlss_libraries_of_a_linux_build_are_the_shared_objects() + { + var files = new InMemoryFileSystem() + .WithDirectory(TestInstallation.Root) + .WithExecutable(TestInstallation.At("eu07"), "\u007fELF binary") + .WithFile(TestInstallation.At("libnvidia-ngx-dlssg.so.310.4.0"), "so"); + var installation = new TestInstallation(files); + + Assert.Equal((false, true), installation.Executables.DlssLibraries(TestInstallation.At("eu07"))); + } + + [Fact] + public void A_later_line_moved_up_keeps_the_index_of_every_key() + { + var c = ConfigFile.Parse("a 1\nb 2\nc 3\n"); + + c.MoveAfter("a", "c"); + + Assert.Equal("b 2\nc 3\na 1\n", c.ToText().Replace("\r\n", "\n")); + Assert.True(c.LineOf("a") > c.LineOf("c")); + c.Set("b", "9"); + Assert.Equal("b 9\nc 3\na 1\n", c.ToText().Replace("\r\n", "\n")); + } +} diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 744912b..5ca8820 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -50,7 +50,7 @@ public sealed class AppServices State = new AppState(); Executables = new ExecutableLocator(files, paths, environment, log); - SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables); + SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(dlssPlatform: !environment.IsMacOS), Executables); MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log); Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath()); StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, Executables, files, paths, diff --git a/StarterNG/Application/SettingsStore.cs b/StarterNG/Application/SettingsStore.cs index bb56352..718852f 100644 --- a/StarterNG/Application/SettingsStore.cs +++ b/StarterNG/Application/SettingsStore.cs @@ -75,6 +75,68 @@ public sealed class SettingsStore TouchConfigAge(); } + /// Whether the simulator can have DLSS on this platform at all. + public bool DlssPlatform => _serializer.DlssPlatform; + + /// + /// Takes the settings of another eu07.ini (a backup, a file from another machine) + /// into the current ones, keeping what belongs to this installation: the chosen + /// simulator, the last scenery and the window state. The lines of this user's + /// file the starter does not manage stay as they are. Nothing is written until Save. + /// + public bool ImportFrom(string path) + { + ConfigFile imported; + try + { + imported = ConfigFile.Parse(_files.ReadAllText(path, _encoding)); + } + catch (Exception ex) + { + _log.Log($"importing {path}", ex); + return false; + } + + var s = Settings; + bool selectAutomatically = s.SelectExeAutomatically; + string executable = s.ExecutablePath; + string lastScenery = s.LastScenery; + bool maximized = s.WindowMaximized; + + _serializer.Read(s, imported); + + s.SelectExeAutomatically = selectAutomatically; + s.ExecutablePath = executable; + s.LastScenery = lastScenery; + s.WindowMaximized = maximized; + return true; + } + + /// + /// Writes the settings as a complete eu07.ini somewhere else - this user's file + /// with the settings applied - leaving the file itself and its state alone. + /// + public bool ExportTo(string path) + { + var copy = ConfigFile.Parse(_config.ToText()); + _serializer.Write(Settings, copy); + + try + { + string? directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + _files.CreateDirectory(directory); + + _files.WriteAllText(path, copy.ToText(), _encoding); + return true; + } + catch (Exception ex) + { + _log.Log($"exporting {path}", ex); + return false; + } + } + public void CaptureAndSave() { _capture?.CaptureInto(Settings); diff --git a/StarterNG/Classes/ConfigFile.cs b/StarterNG/Classes/ConfigFile.cs index e551b2c..8ec2c3a 100644 --- a/StarterNG/Classes/ConfigFile.cs +++ b/StarterNG/Classes/ConfigFile.cs @@ -32,6 +32,28 @@ public sealed class ConfigFile public bool Has(string key) => _index.ContainsKey(key); + /// The line the key is on, or -1. The simulator reads the file top down, so a later line wins. + public int LineOf(string key) => _index.TryGetValue(key, out int i) ? i : -1; + + /// + /// Moves the line of right below that of + /// when it is above it, for keys the simulator lets override one another. + /// + public void MoveAfter(string key, string anchor) + { + if (!_index.TryGetValue(key, out int from) || !_index.TryGetValue(anchor, out int to) || from > to) + return; + + string line = _lines[from]; + _lines.RemoveAt(from); + _lines.Insert(to, line); + + _index.Clear(); + for (int i = 0; i < _lines.Count; i++) + if (TryParseKey(_lines[i], out string k)) + _index[k] = i; + } + public string? GetRaw(string key) { if (!_index.TryGetValue(key, out int i)) diff --git a/StarterNG/Domain/Settings/GraphicsPresets.cs b/StarterNG/Domain/Settings/GraphicsPresets.cs new file mode 100644 index 0000000..cded183 --- /dev/null +++ b/StarterNG/Domain/Settings/GraphicsPresets.cs @@ -0,0 +1,151 @@ +using System; + +namespace StarterNG.Domain.Settings; + +public enum GraphicsPreset { Lowest, Low, Medium, High, Ultra, Custom } + +/// +/// Quality presets of the starter: one set of graphics settings each. A preset +/// leaves alone what is a matter of taste or of the machine rather than of quality +/// (resolution, render scale, vsync, field of view, motion blur, chromatic +/// aberration) and keeps DLSS when it is on, since that is a choice of the +/// anti-aliasing the presets cannot make better. Ray tracing is only for Vulkan. +/// +public static class GraphicsPresets +{ + public static readonly GraphicsPreset[] All = + { + GraphicsPreset.Lowest, GraphicsPreset.Low, GraphicsPreset.Medium, GraphicsPreset.High, GraphicsPreset.Ultra + }; + + /// The settings a preset governs. Doubles are kept in hundredths so equality is exact. + private sealed record Quality( + int Texture, int CabTexture, int Anisotropy, AntiAliasingMode AntiAliasing, + int DrawRange, int Spline, int DynamicLights, + bool Shadows, int ShadowMap, int ShadowRange, int ShadowRank, int LightShadows, int ContactShadows, + int Ao, int AoSlices, int AoSteps, + int ReflectionsFidelity, int ReflectionsFramerate, int Ssr, bool RayTracing, + bool VolumetricFog, int FogQuality, int Clouds, bool CloudShadows, + bool Grass, int GrassDistance, int GrassDensityPercent, + int SmokeFidelity, int Precipitation, bool Bloom, bool ExtraEffects); + + private static Quality Table(GraphicsPreset preset) => preset switch + { + GraphicsPreset.Lowest => new Quality( + 1024, 1024, 1, AntiAliasingMode.Off, + 1, 1, 3, + false, 1024, 75, 1, 0, 0, + 0, 3, 4, + 0, 5, 0, false, + false, 1, 0, false, + false, 60, 100, + 1, 16384, false, false), + GraphicsPreset.Low => new Quality( + 2048, 2048, 4, AntiAliasingMode.Msaa2, + 2, 2, 5, + true, 1024, 150, 2, 1, 0, + 1, 3, 4, + 0, 10, 1, false, + true, 1, 1, false, + true, 40, 50, + 1, 32768, true, true), + GraphicsPreset.Medium => new Quality( + 4096, 4096, 8, AntiAliasingMode.Msaa2, + 3, 3, 7, + true, 2048, 250, 3, 2, 0, + 2, 3, 4, + 1, 30, 2, false, + true, 2, 2, true, + true, 60, 100, + 2, 65536, true, true), + GraphicsPreset.High => new Quality( + 4096, 4096, 16, AntiAliasingMode.Msaa4, + 3, 4, 7, + true, 4096, 300, 3, 3, 1, + 2, 4, 6, + 2, 30, 2, false, + true, 3, 3, true, + true, 90, 100, + 3, 65536, true, true), + GraphicsPreset.Ultra => new Quality( + 8192, 8192, 16, AntiAliasingMode.Msaa8, + 3, 4, 7, + true, 8192, 400, 3, 3, 3, + 2, 6, 8, + 2, 60, 3, true, + true, 4, 4, true, + true, 120, 150, + 4, 131072, true, true), + _ => throw new ArgumentOutOfRangeException(nameof(preset), preset, null) + }; + + /// A preset as it lands on these settings: DLSS kept where it runs, ray tracing on Vulkan only. + private static Quality Resolved(GraphicsPreset preset, SimulatorSettings s, bool dlssPlatform) + { + var q = Table(preset); + if (GraphicsRules.DlssActive(s, dlssPlatform)) + q = q with { AntiAliasing = s.AntiAliasing }; + if (!GraphicsRules.RayTracingAllowed(s)) + q = q with { RayTracing = false }; + return q; + } + + private static Quality Of(SimulatorSettings s) => new( + s.MaxTextureSize, s.MaxCabTextureSize, s.TextureFiltering, s.AntiAliasing, + (int)Math.Round(s.DrawRangeFactor), s.SplineFidelity, s.DynamicLights, + s.RenderShadows, s.ShadowMapResolution, s.ShadowProjectionRange, s.ShadowRankCutoff, s.LightShadows, s.ContactShadows, + s.AmbientOcclusion, s.AoSlices, s.AoSteps, + s.ReflectionsFidelity, s.ReflectionsFramerate, s.Ssr, s.RayTracing, + s.VolumetricFog, s.FogQuality, s.Clouds, s.CloudShadows, + s.Grass, s.GrassDistance, (int)Math.Round(s.GrassDensity * 100), + s.SmokeFidelity, s.PrecipitationParticles, s.Bloom, s.ExtraEffects); + + public static void Apply(SimulatorSettings s, GraphicsPreset preset, bool dlssPlatform) + { + if (preset == GraphicsPreset.Custom) + return; + + var q = Resolved(preset, s, dlssPlatform); + s.MaxTextureSize = q.Texture; + s.MaxCabTextureSize = q.CabTexture; + s.TextureFiltering = q.Anisotropy; + s.AntiAliasing = q.AntiAliasing; + s.DrawRangeFactor = q.DrawRange; + s.SplineFidelity = q.Spline; + s.DynamicLights = q.DynamicLights; + s.RenderShadows = q.Shadows; + s.ShadowMapResolution = q.ShadowMap; + s.ShadowProjectionRange = q.ShadowRange; + s.ShadowRankCutoff = q.ShadowRank; + s.LightShadows = q.LightShadows; + s.ContactShadows = q.ContactShadows; + s.AmbientOcclusion = q.Ao; + s.AoSlices = q.AoSlices; + s.AoSteps = q.AoSteps; + s.ReflectionsFidelity = q.ReflectionsFidelity; + s.ReflectionsFramerate = q.ReflectionsFramerate; + s.Ssr = q.Ssr; + s.RayTracing = q.RayTracing; + s.VolumetricFog = q.VolumetricFog; + s.FogQuality = q.FogQuality; + s.Clouds = q.Clouds; + s.CloudShadows = q.CloudShadows; + s.Grass = q.Grass; + s.GrassDistance = q.GrassDistance; + s.GrassDensity = q.GrassDensityPercent / 100.0; + s.SmokeFidelity = q.SmokeFidelity; + s.PrecipitationParticles = q.Precipitation; + s.Bloom = q.Bloom; + s.ExtraEffects = q.ExtraEffects; + } + + /// The preset these settings are exactly, or Custom. + public static GraphicsPreset Detect(SimulatorSettings s, bool dlssPlatform) + { + var current = Of(s); + foreach (var preset in All) + if (Resolved(preset, s, dlssPlatform) == current) + return preset; + return GraphicsPreset.Custom; + } +} diff --git a/StarterNG/Domain/Settings/GraphicsRules.cs b/StarterNG/Domain/Settings/GraphicsRules.cs new file mode 100644 index 0000000..b4830e5 --- /dev/null +++ b/StarterNG/Domain/Settings/GraphicsRules.cs @@ -0,0 +1,105 @@ +namespace StarterNG.Domain.Settings; + +/// +/// What the simulator does with combinations of graphics settings, mirrored here so +/// the starter only offers what takes effect and writes eu07.ini the way the +/// simulator will run it (utilities/Globals.cpp, rendering/vulkan14, rendering/opengl46). +/// +public static class GraphicsRules +{ + public static bool IsDlss(AntiAliasingMode mode) => mode is AntiAliasingMode.Dlss or AntiAliasingMode.Dlaa; + + public static bool IsTemporal(AntiAliasingMode mode) => mode == AntiAliasingMode.Taa || IsDlss(mode); + + public static bool IsVulkan(SimulatorSettings s) => s.Backend == GraphicsBackend.Vulkan; + + /// + /// DLSS is the Vulkan renderer's only (the OpenGL one takes TAA instead), and the + /// DLSS SDK has no macOS libraries. + /// + public static bool DlssAllowed(SimulatorSettings s, bool dlssPlatform) => dlssPlatform && IsVulkan(s); + + public static bool DlssActive(SimulatorSettings s, bool dlssPlatform) => + DlssAllowed(s, dlssPlatform) && IsDlss(s.AntiAliasing); + + /// The frame generation goes with gfx.antialiasing dlss or dlaa, nothing else. + public static bool FrameGenerationAllowed(SimulatorSettings s, bool dlssPlatform) => DlssActive(s, dlssPlatform); + + /// Reflex (VK_NV_low_latency2) is the Vulkan renderer's only. + public static bool ReflexAllowed(SimulatorSettings s) => IsVulkan(s); + + /// The frame generation turns Reflex on whatever gfx.reflex says. + public static bool ReflexForced(SimulatorSettings s, bool dlssPlatform) => + FrameGenerationAllowed(s, dlssPlatform) && s.FrameGeneration > 1; + + /// Ray traced reflections: the Vulkan renderer on a GPU with ray queries. + public static bool RayTracingAllowed(SimulatorSettings s) => IsVulkan(s); + + /// The render scale is ignored by DLAA, which always draws at the size of the window. + public static bool BufferScaleUsed(SimulatorSettings s, bool dlssPlatform) => + !(DlssActive(s, dlssPlatform) && s.AntiAliasing == AntiAliasingMode.Dlaa); + + /// TAA and DLSS run in the post-processing which gfx.skippipeline leaves out. + public static bool TemporalWithoutPipeline(SimulatorSettings s) => s.SkipPipeline && IsTemporal(s.AntiAliasing); + + /// With the clustered lights the headlights come from the clusters; dynamiclights only caps the old path. + public static bool DynamicLightsUsed(SimulatorSettings s) => !s.LightsClustered; + + /// + /// Brings the settings to what the simulator would run with: the Vulkan-only + /// features off on OpenGL, DLSS falling back to TAA, values within the ranges + /// the simulator clamps them to. + /// + public static void Normalize(SimulatorSettings s, bool dlssPlatform) + { + if (IsDlss(s.AntiAliasing) && !DlssAllowed(s, dlssPlatform)) + s.AntiAliasing = AntiAliasingMode.Taa; + + s.FrameGeneration = FrameGenerationAllowed(s, dlssPlatform) ? Clamp(s.FrameGeneration, 1, 4) : 1; + s.Reflex = ReflexAllowed(s) ? Clamp(s.Reflex, 0, 2) : 0; + if (!RayTracingAllowed(s)) + s.RayTracing = false; + + if (!IsKnownDlssPreset(s.DlssPreset)) + s.DlssPreset = "default"; + + s.MaxTextureSize = PowerOfTwo(s.MaxTextureSize, 64, 8192); + s.MaxCabTextureSize = PowerOfTwo(s.MaxCabTextureSize, 512, 8192); + s.ShadowMapResolution = PowerOfTwo(s.ShadowMapResolution, 512, 8192); + s.BufferScalePercent = Clamp(s.BufferScalePercent, 25, 200); + s.LightShadows = Clamp(s.LightShadows, 0, 3); + s.ContactShadows = Clamp(s.ContactShadows, 0, 3); + s.AmbientOcclusion = Clamp(s.AmbientOcclusion, 0, 2); + s.AoSlices = Clamp(s.AoSlices, 1, 8); + s.AoSteps = Clamp(s.AoSteps, 1, 16); + s.Ssr = Clamp(s.Ssr, 0, 3); + s.RayTracingRange = Clamp(s.RayTracingRange, 10, 5000); + s.FogQuality = Clamp(s.FogQuality, 1, 4); + s.Clouds = Clamp(s.Clouds, 0, 4); + s.GrassDistance = Clamp(s.GrassDistance, 5, 500); + s.GrassDensity = s.GrassDensity < 0 ? 0 : (s.GrassDensity > 4 ? 4 : s.GrassDensity); + s.PrecipitationParticles = Clamp(s.PrecipitationParticles, 0, 1 << 20); + } + + public static readonly string[] DlssPresets = { "default", "j", "k", "l", "m" }; + + public static bool IsKnownDlssPreset(string? preset) + { + foreach (string known in DlssPresets) + if (string.Equals(known, preset, System.StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); + + /// The power of two nearest below, as the simulator rounds texture and shadow map sizes. + private static int PowerOfTwo(int v, int lo, int hi) + { + v = Clamp(v, lo, hi); + int p = lo; + while (p * 2 <= v) + p *= 2; + return p; + } +} diff --git a/StarterNG/Domain/Settings/SimulatorSettings.cs b/StarterNG/Domain/Settings/SimulatorSettings.cs index e1dea3c..90d8918 100644 --- a/StarterNG/Domain/Settings/SimulatorSettings.cs +++ b/StarterNG/Domain/Settings/SimulatorSettings.cs @@ -7,6 +7,12 @@ public enum ConsistReadyOverride { Scenery = 0, AlwaysCold = 1, AlwaysReady = 2 public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform } +// gfxrenderer: the opengl 4.6 renderer ("full", which the simulator reads as "default") or the vulkan 1.4 one. +public enum GraphicsBackend { OpenGL = 0, Vulkan = 1 } + +// gfx.antialiasing. Dlss and Dlaa are the vulkan renderer's only; the simulator falls back to taa elsewhere. +public enum AntiAliasingMode { Off, Msaa2, Msaa4, Msaa8, Taa, Dlss, Dlaa } + public sealed class SimulatorSettings { public bool LanguageWasSet { get; internal set; } @@ -37,14 +43,18 @@ public sealed class SimulatorSettings public bool LogMissingVehicleFiles; public bool DeveloperTools; - public int RenderEngine; + public GraphicsBackend Backend = GraphicsBackend.OpenGL; + public string VulkanDevice = ""; 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 AntiAliasingMode AntiAliasing = AntiAliasingMode.Msaa4; + public string DlssPreset = "default"; + public int FrameGeneration = 1; + public int Reflex; public int DynamicLights = 7; public double DrawRangeFactor = 3.0; public bool VSync; @@ -54,8 +64,27 @@ public sealed class SimulatorSettings public bool MotionBlur = true; public bool ExtraEffects = true; public bool EnvMap = true; - public bool UseVbo = true; public bool RenderShadows = true; + public bool ShadowSdsm; + public int LightShadows = 2; + public int ContactShadows; + public bool LightsClustered = true; + public int AmbientOcclusion = 2; + public int AoSlices = 3; + public int AoSteps = 4; + public int Ssr = 2; + public bool RayTracing; + public int RayTracingRange = 500; + public bool VolumetricFog = true; + public int FogQuality = 2; + public int Clouds = 2; + public bool CloudShadows = true; + public bool Grass = true; + public int GrassDistance = 60; + public double GrassDensity = 1.0; + public int PrecipitationParticles = 65536; + public bool Bloom = true; + public bool AutoExposure = true; public int ReflectionsFramerate = 60; public int ShadowMapResolution = 2048; public int ShadowProjectionRange = 250; @@ -69,7 +98,6 @@ public sealed class SimulatorSettings public int FpsLimit = 30; public double ShadowAngleLimit = 0.2; public bool FullscreenWindowed; - public bool RenderAngleVulkan; public int SplineFidelity = 1; public bool FullPhysics = true; @@ -97,7 +125,6 @@ public sealed class SimulatorSettings public bool CompressTextures = true; public bool ScaleSpeculars = true; - public bool UseGLES; public bool ShaderGamma; public bool ScreenMipmaps = true; public bool ExtendedModelConversion; @@ -120,8 +147,8 @@ public sealed class SimulatorSettings public bool WindowMaximized; - public static readonly string[] RenderEngines = - { "full", "legacy", "simpleshader", "simple", "off", "experimental" }; + // "full" rather than "default": every simulator build reads it, the new ones as the opengl 4.6 renderer. + public static readonly string[] RenderEngines = { "full", "vulkan" }; public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 }; diff --git a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs index cf580d2..3c6bd4b 100644 --- a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs +++ b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs @@ -246,6 +246,37 @@ public sealed class ExecutableLocator return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage"; } + /// + /// Whether the NVIDIA libraries of DLSS and of its frame generation sit next to + /// the simulator, where it loads them from; without them it runs with TAA. + /// The names follow the build: a Windows one wants the DLLs, a Linux one the .so files. + /// + public (bool Dlss, bool FrameGeneration) DlssLibraries(string executable) + { + try + { + // Resolve hands back either a path of its own search or the bare name of the pick. + string full = _files.FileExists(executable) ? executable : FullPath(executable); + string? directory = Path.GetDirectoryName(full); + if (string.IsNullOrEmpty(directory) || !_files.DirectoryExists(directory)) + return (false, false); + + bool windowsBuild = _files.FileExists(full) + ? Format(full) == BinaryFormat.PortableExecutable + : _environment.IsWindows; + + return windowsBuild + ? (Has(directory, "nvngx_dlss.dll"), Has(directory, "nvngx_dlssg.dll")) + : (Has(directory, "libnvidia-ngx-dlss.so*"), Has(directory, "libnvidia-ngx-dlssg.so*")); + } + catch (Exception) + { + return (false, false); + } + } + + private bool Has(string directory, string pattern) => _files.GetFiles(directory, pattern).Count > 0; + private string FullPath(string path) => Path.IsPathRooted(path) ? path : Path.Combine(_paths.Root, path); } diff --git a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs index 79260d8..e33cbd9 100644 --- a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs +++ b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs @@ -10,6 +10,16 @@ namespace StarterNG.Infrastructure.Settings; public sealed class SettingsSerializer { + private readonly bool _dlssPlatform; + + /// Whether the simulator can have DLSS here at all (not on macOS). + public SettingsSerializer(bool dlssPlatform = true) + { + _dlssPlatform = dlssPlatform; + } + + public bool DlssPlatform => _dlssPlatform; + public void Read(SimulatorSettings s, ConfigFile c) { s.LanguageWasSet = c.Has("lang"); @@ -58,14 +68,18 @@ public sealed class SettingsSerializer s.LogMissingVehicleFiles = c.GetBool("starter.logmissingvehicles", false); s.DeveloperTools = c.GetBool("starter.developertools", false); - s.RenderEngine = IndexOf(SimulatorSettings.RenderEngines, c.GetString("gfxrenderer", "full"), 0); + s.Backend = ParseBackend(c.GetString("gfxrenderer", "full")); + s.VulkanDevice = c.GetString("gfx.vulkan.device", ""); s.Width = c.GetInt("width", 1280); s.Height = c.GetInt("height", 720); - s.BufferScalePercent = Clamp(c.GetInt("starter.bufferscale", 100), 50, 200); + s.BufferScalePercent = ReadBufferScale(c); 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.AntiAliasing = ReadAntiAliasing(c); + s.DlssPreset = c.GetString("gfx.dlss.preset", "default").ToLowerInvariant(); + s.FrameGeneration = ParseFrameGeneration(FirstToken(c, "gfx.framegeneration")); + s.Reflex = ParseReflex(FirstToken(c, "gfx.reflex")); 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); @@ -75,8 +89,28 @@ public sealed class SettingsSerializer 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.ShadowSdsm = c.GetBool("gfx.shadow.sdsm", false); + s.LightShadows = ParseLevel(FirstToken(c, "gfx.lights.shadows"), 2, 3); + s.ContactShadows = ParseLevel(FirstToken(c, "gfx.contactshadows"), 0, 3); + s.LightsClustered = c.GetBool("gfx.lights.clustered", true); + s.AmbientOcclusion = ParseAmbientOcclusion(FirstToken(c, "gfx.postfx.ao")); + var aoQuality = c.GetTokens("gfx.postfx.ao.quality"); + s.AoSlices = aoQuality.Length >= 1 ? ParseInt(aoQuality[0], 3) : 3; + s.AoSteps = aoQuality.Length >= 2 ? ParseInt(aoQuality[1], 4) : 4; + s.Ssr = ParseLevel(FirstToken(c, "gfx.ssr"), 2, 3); + s.RayTracing = c.GetBool("gfx.raytracing", false); + s.RayTracingRange = (int)Math.Round(c.GetDouble("gfx.raytracing.range", 500)); + s.VolumetricFog = ParseVolumetricFog(FirstToken(c, "gfx.fog")); + s.FogQuality = ParseFogQuality(FirstToken(c, "gfx.fog.quality")); + s.Clouds = ParseLevel(FirstToken(c, "gfx.clouds"), 2, 4); + s.CloudShadows = c.GetBool("gfx.clouds.shadows", true); + s.Grass = c.GetBool("gfx.grass", true); + s.GrassDistance = (int)Math.Round(c.GetDouble("gfx.grass.distance", 60)); + s.GrassDensity = c.GetDouble("gfx.grass.density", 1.0); + s.PrecipitationParticles = c.GetInt("gfx.precipitation.particles", 65536); + s.Bloom = c.GetBool("gfx.postfx.bloom", true); + s.AutoExposure = c.GetBool("gfx.postfx.autoexposure", true); s.ReflectionsFramerate = c.GetInt("gfx.reflections.framerate", 60); var shadowTune = c.GetTokens("shadowtune"); @@ -94,7 +128,6 @@ public sealed class SettingsSerializer 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); @@ -125,7 +158,6 @@ public sealed class SettingsSerializer 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; @@ -151,15 +183,25 @@ public sealed class SettingsSerializer s.HideArchivalVehicles = c.GetBool("starter.hide.archivalvehicles", true); s.LastScenery = c.GetString("starter.last.scenery", ""); s.WindowMaximized = c.GetBool("starter.window.maximized", false); + + GraphicsRules.Normalize(s, _dlssPlatform); } + // Keys the simulator no longer reads (it logs each of them on every start); commented out on save. private static readonly string[] ObsoleteKeys = { "gfx.postfx.tonemapping", + "usevbo", + "gfx.angleplatform", + "gfx.usegles", + "gfx.framebuffer.width", + "gfx.framebuffer.height", }; public void Write(SimulatorSettings s, ConfigFile c) { + GraphicsRules.Normalize(s, _dlssPlatform); + foreach (string key in ObsoleteKeys) c.Remove(key); @@ -198,18 +240,24 @@ public sealed class SettingsSerializer c.SetBool("starter.logmissingvehicles", s.LogMissingVehicleFiles); c.SetBool("starter.developertools", s.DeveloperTools); - c.Set("gfxrenderer", SimulatorSettings.RenderEngines[Clamp(s.RenderEngine, 0, SimulatorSettings.RenderEngines.Length - 1)]); + c.Set("gfxrenderer", SimulatorSettings.RenderEngines[(int)s.Backend]); + if (!string.IsNullOrWhiteSpace(s.VulkanDevice)) c.Set("gfx.vulkan.device", s.VulkanDevice.Trim()); else c.Remove("gfx.vulkan.device"); 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); + // Over 4 the simulator takes it as percents. + c.SetInt("gfx.buffer.percent", s.BufferScalePercent); c.SetInt("maxtexturesize", s.MaxTextureSize); c.SetInt("maxcabtexturesize", s.MaxCabTextureSize); c.SetInt("anisotropicfiltering", s.TextureFiltering); - c.SetInt("multisampling", s.Multisampling); + // multisampling is what older simulators read; gfx.antialiasing overrides it in the new ones, but only from + // a later line, since each key sets the mode as it is read. + c.SetInt("multisampling", MultisamplingLevel(s.AntiAliasing)); + c.Set("gfx.antialiasing", AntiAliasingToken(s.AntiAliasing)); + c.MoveAfter("gfx.antialiasing", "multisampling"); + c.Set("gfx.dlss.preset", s.DlssPreset); + c.Set("gfx.framegeneration", s.FrameGeneration > 1 ? $"x{s.FrameGeneration}" : "off"); + c.Set("gfx.reflex", s.Reflex switch { 1 => "on", 2 => "boost", _ => "off" }); c.SetInt("dynamiclights", s.DynamicLights); c.SetDouble("gfx.drawrange.factor.max", s.DrawRangeFactor); c.SetBool("vsync", s.VSync); @@ -219,9 +267,29 @@ public sealed class SettingsSerializer 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.SetBool("gfx.shadow.sdsm", s.ShadowSdsm); + c.Set("gfx.lights.shadows", LevelToken(s.LightShadows)); + c.Set("gfx.contactshadows", LevelToken(s.ContactShadows)); + c.SetBool("gfx.lights.clustered", s.LightsClustered); + c.Set("gfx.postfx.ao", s.AmbientOcclusion switch { 1 => "ssao", 2 => "gtao", _ => "no" }); + c.Set("gfx.postfx.ao.quality", $"{s.AoSlices} {s.AoSteps}"); + c.Set("gfx.ssr", LevelToken(s.Ssr)); + c.SetBool("gfx.raytracing", s.RayTracing); + c.SetInt("gfx.raytracing.range", s.RayTracingRange); + c.Set("gfx.fog", s.VolumetricFog ? "yes" : "analytic"); + c.Set("gfx.fog.quality", LevelToken(s.FogQuality)); + c.Set("gfx.clouds", LevelToken(s.Clouds)); + c.SetBool("gfx.clouds.shadows", s.CloudShadows); + c.SetBool("gfx.grass", s.Grass); + c.SetInt("gfx.grass.distance", s.GrassDistance); + // The grass thins out past its distance; the simulator's own default keeps a sixth of it for that. + c.SetInt("gfx.grass.fadedistance", Math.Min(600, s.GrassDistance + Math.Max(10, s.GrassDistance / 6))); + c.SetDouble("gfx.grass.density", s.GrassDensity); + c.SetInt("gfx.precipitation.particles", s.PrecipitationParticles); + c.SetBool("gfx.postfx.bloom", s.Bloom); + c.SetBool("gfx.postfx.autoexposure", s.AutoExposure); c.SetInt("gfx.reflections.framerate", s.ReflectionsFramerate); c.Set("shadowtune", $"{s.ShadowMapResolution} {s.ShadowProjectionRange} " + @@ -235,7 +303,6 @@ public sealed class SettingsSerializer 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); @@ -262,7 +329,6 @@ public sealed class SettingsSerializer 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"); @@ -291,14 +357,134 @@ public sealed class SettingsSerializer 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) + private static string FirstToken(ConfigFile c, string key) { - for (int i = 0; i < values.Length; i++) - if (string.Equals(values[i], token, StringComparison.OrdinalIgnoreCase)) - return i; - return fallback; + var tokens = c.GetTokens(key); + return tokens.Length > 0 ? tokens[0].ToLowerInvariant() : string.Empty; } + // The aliases the simulator accepts; the removed renderers ("legacy", "simple", "experimental", ...) run as opengl. + private static GraphicsBackend ParseBackend(string value) => + value.Trim().ToLowerInvariant() is "vulkan" or "vulkan14" ? GraphicsBackend.Vulkan : GraphicsBackend.OpenGL; + + private static int ReadBufferScale(ConfigFile c) + { + if (c.Has("gfx.buffer.percent")) + { + double value = c.GetDouble("gfx.buffer.percent", 100); + // As the simulator reads it: up to 4 a part of the window, over it percents. + int percent = (int)Math.Round(value > 4 ? value : value * 100); + return Clamp(percent, 25, 200); + } + return Clamp(c.GetInt("starter.bufferscale", 100), 25, 200); + } + + // Whichever of the two keys comes later in the file decides, as each sets the mode when the simulator reads it. + private static AntiAliasingMode ReadAntiAliasing(ConfigFile c) + { + var fromMultisampling = c.GetInt("multisampling", 2) switch + { + <= 0 => AntiAliasingMode.Off, + 1 => AntiAliasingMode.Msaa2, + 2 => AntiAliasingMode.Msaa4, + _ => AntiAliasingMode.Msaa8 + }; + + if (!c.Has("gfx.antialiasing") || + (c.Has("multisampling") && c.LineOf("multisampling") > c.LineOf("gfx.antialiasing"))) + return fromMultisampling; + + return FirstToken(c, "gfx.antialiasing") switch + { + "off" or "no" or "none" or "0" => AntiAliasingMode.Off, + "msaax2" => AntiAliasingMode.Msaa2, + "msaax4" => AntiAliasingMode.Msaa4, + "msaax8" or "msaax16" => AntiAliasingMode.Msaa8, + "taa" => AntiAliasingMode.Taa, + "dlss" => AntiAliasingMode.Dlss, + "dlaa" => AntiAliasingMode.Dlaa, + _ => fromMultisampling + }; + } + + private static string AntiAliasingToken(AntiAliasingMode mode) => mode switch + { + AntiAliasingMode.Msaa2 => "msaax2", + AntiAliasingMode.Msaa4 => "msaax4", + AntiAliasingMode.Msaa8 => "msaax8", + AntiAliasingMode.Taa => "taa", + AntiAliasingMode.Dlss => "dlss", + AntiAliasingMode.Dlaa => "dlaa", + _ => "off" + }; + + private static int MultisamplingLevel(AntiAliasingMode mode) => mode switch + { + AntiAliasingMode.Msaa2 => 1, + AntiAliasingMode.Msaa4 => 2, + AntiAliasingMode.Msaa8 => 3, + _ => 0 + }; + + private static int ParseFrameGeneration(string value) => value switch + { + "x2" or "on" or "yes" or "2" => 2, + "x3" or "3" => 3, + "x4" or "4" => 4, + _ => 1 + }; + + private static int ParseReflex(string value) => value switch + { + "on" or "yes" or "1" => 1, + "boost" or "2" => 2, + _ => 0 + }; + + // no/low/medium/high(/ultra) or the number of the level; an absent key keeps the simulator's default. + private static int ParseLevel(string value, int fallback, int max) + { + if (value.Length == 0) + return fallback; + int level = value switch + { + "low" or "1" => 1, + "medium" or "yes" or "true" or "2" => 2, + "high" or "3" => 3, + "ultra" or "4" => 4, + _ => 0 + }; + return Math.Min(level, max); + } + + private static string LevelToken(int level) => level switch + { + 1 => "low", + 2 => "medium", + 3 => "high", + 4 => "ultra", + _ => "no" + }; + + private static int ParseAmbientOcclusion(string value) => value switch + { + "" => 2, + "yes" or "true" or "1" or "gtao" => 2, + "ssao" => 1, + _ => 0 + }; + + private static bool ParseVolumetricFog(string value) => + value is not ("analytic" or "old" or "no" or "false" or "0"); + + private static int ParseFogQuality(string value) => value switch + { + "low" or "1" => 1, + "high" or "3" => 3, + "ultra" or "4" => 4, + _ => 2 + }; + private static int MapOldPyScreenPriority(string priority) => priority.ToLowerInvariant() switch { diff --git a/StarterNG/Services/LocalizationService.cs b/StarterNG/Services/LocalizationService.cs index 8357d95..1e7cb6c 100644 --- a/StarterNG/Services/LocalizationService.cs +++ b/StarterNG/Services/LocalizationService.cs @@ -140,6 +140,35 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings { var dict = new Dictionary(StringComparer.Ordinal); + // A translation which has not caught up with new settings shows them in English + // rather than as their raw keys. Descriptions stay out: a missing one collapses. + string english = Path.Combine(Path.GetDirectoryName(path) ?? LangDirectory, "en.xml"); + if (!string.Equals(code, "en", StringComparison.OrdinalIgnoreCase) && File.Exists(english)) + { + try + { + ReadStrings(english, dict); + foreach (var key in new List(dict.Keys)) + if (key.EndsWith(OptionalSuffix, StringComparison.Ordinal)) + dict.Remove(key); + } + catch (Exception ex) + { + _log.Log($"lang/{Path.GetFileName(english)}", ex); + } + } + + ReadStrings(path, dict); + + _strings = dict; + lock (_reportedMisses) + _reportedMisses.Clear(); + CurrentLangCode = code; + CurrentLanguage = name; + } + + private static void ReadStrings(string path, Dictionary dict) + { var settings = new XmlReaderSettings { IgnoreComments = true, IgnoreWhitespace = true }; using var reader = XmlReader.Create(path, settings); @@ -157,12 +186,6 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings reader.Read(); } } - - _strings = dict; - lock (_reportedMisses) - _reportedMisses.Clear(); - CurrentLangCode = code; - CurrentLanguage = name; } private static (string code, string name) ReadHeader(string path) diff --git a/StarterNG/Styles/Settings.axaml b/StarterNG/Styles/Settings.axaml index c5a8ca4..882ed32 100644 --- a/StarterNG/Styles/Settings.axaml +++ b/StarterNG/Styles/Settings.axaml @@ -197,6 +197,21 @@ + + + + + - + + + diff --git a/StarterNG/Styles/Settings.axaml b/StarterNG/Styles/Settings.axaml index c5a8ca4..1f0e1cd 100644 --- a/StarterNG/Styles/Settings.axaml +++ b/StarterNG/Styles/Settings.axaml @@ -11,7 +11,7 @@ - + + + @@ -99,6 +111,24 @@ + + + + + + @@ -118,6 +148,13 @@ + + + + + + + + + + + + + + + + + diff --git a/StarterNG/Views/Depot.axaml.cs b/StarterNG/Views/Depot.axaml.cs index af9f5fe..5d3efe9 100644 --- a/StarterNG/Views/Depot.axaml.cs +++ b/StarterNG/Views/Depot.axaml.cs @@ -1,19 +1,12 @@ using System; using System.Collections.Generic; -using System.Globalization; using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Controls.Templates; using Avalonia.Input; -using Avalonia.Input.Platform; -using Avalonia.Layout; using Avalonia.Media; -using Avalonia.Media.Imaging; using Avalonia.Threading; using Avalonia.VisualTree; using StarterNG.Classes; @@ -54,6 +47,16 @@ public partial class Depot : UserControl private Scenery? _listScenery; + private readonly ConsistHistory _history = new(); + + /// Set while an undo or redo puts a state back, which is not a change of its own. + private bool _restoring; + + private readonly WarehouseEdit _warehouseEdit = new(); + + private bool EditingWarehouse => + _warehouseEdit.Trainset is not null && ReferenceEquals(_consist.EditingTrainset, _warehouseEdit.Trainset); + public Depot() { _info = new VehicleInfo(_db, AppServices.Current.Physics); @@ -67,13 +70,19 @@ public partial class Depot : UserControl return; _warehouse = new Warehouse(warehouseList, removeFromWarehouseButton, _hand, - () => _consist.EditingTrainset, ApplyConsist); + () => AppServices.Current.State.CurrentTrainset, ApplyFromWarehouse); + _warehouse.SelectionChanged += () => + { + if (WarehouseTabOpen) + EditWarehouseSelection(); + }; + _warehouse.Renamed += _warehouseEdit.Renamed; _browser = new VehicleBrowser(categoryCombo, classCombo, vehicleListBox, searchBox, - hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis, - () => TextureBaseButton_OnClick(null, null!)) + hideArchivalCheck, miniPreview, addVehicleButton, _db, _minis, OpenVehicleBase) { - AddMany = (texture, count) => AddTexture(texture, count) + AddMany = (texture, count) => AddTexture(texture, count), + Replace = ReplaceSelected }; _drag = new ConsistDragging(consistStack, consistScroll, consistOverlay, @@ -83,7 +92,13 @@ public partial class Depot : UserControl _cards = new VehicleCards(consistStack, _db, _consist, _cargo, _minis, _hand, RebuildConsist, () => _details.Refresh(), _browser.SelectInBrowser, _drag.ArmCardDrag) { - AddCopies = AddCopiesAfter + AddCopies = AddCopiesAfter, + BrowserSelection = () => _browser.Selected, + ReplaceWith = (item, texture) => + { + _consist.Selected = item; + ReplaceSelected(texture); + } }; // Hidden still scrolls - it only drops the bar itself. @@ -92,8 +107,16 @@ public partial class Depot : UserControl _details = new VehicleDetails( generalPanel, consistTexturePanel, couplerPanel, couplerActions, brakesPanel, loadsPanel, damagePanel, removeVehicleButton, - _db, _info, _consist, _cargo, _hand, - RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo); + _info, _consist, _cargo, _hand, + RebuildConsist, _cards.RefreshMemberChrome, _browser.ShowTextureInfo) + { + // A warehouse consist is kept in its file; a scenery one is the list itself. + Edited = () => + { + if (EditingWarehouse) + _warehouseEdit.Save(); + } + }; _browser.TextureSelected = _details.ShowTexture; @@ -112,22 +135,101 @@ public partial class Depot : UserControl AttachedToVisualTree += (_, _) => SyncFromSelection(); PropertyChanged += (_, e) => { - if (e.Property == IsVisibleProperty && IsVisible) + if (e.Property != IsVisibleProperty) + return; + if (!IsVisible) { - SyncFromSelection(); - _warehouse.Refresh(); + RememberConsistOnLeaving(); + return; } + if (ConsistMovedSinceLeaving()) + ShowSceneryTab(); + SyncFromSelection(); + _warehouse.Refresh(); }; } + private Trainset? _leftTrainset; + + private List _leftVehicles = new(); + + private void RememberConsistOnLeaving() + { + _leftTrainset = AppServices.Current.State.CurrentTrainset; + _leftVehicles = _leftTrainset?.Vehicles.ToList() ?? new List(); + } + + /// Another consist was picked or this one changed on another page: the warehouse tab left open would hide it. + private bool ConsistMovedSinceLeaving() => + AppServices.Current.State.CurrentTrainset is { } trainset && + (!ReferenceEquals(trainset, _leftTrainset) || !trainset.Vehicles.SequenceEqual(_leftVehicles)); + private void AddToWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => - _ = _warehouse.SaveCurrentAsync(); + _warehouse.SaveCurrent((Control)sender!); private void RemoveFromWarehouseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => _ = _warehouse.RemoveSelectedAsync(); - private void WarehouseList_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) => - _warehouse.ApplySelected(); + private bool WarehouseTabOpen => ReferenceEquals(consistSourceTabs.SelectedItem, warehouseTab); + + private void ConsistSourceTabs_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) + { + // The lists inside the tabs raise the same event, and it bubbles up here. + if (!ReferenceEquals(e.Source, consistSourceTabs) || Design.IsDesignMode) + return; + + if (WarehouseTabOpen) + { + // A consist saved from a menu elsewhere went in while this list was not shown. + _warehouse.Refresh(); + EditWarehouseSelection(); + } + else + EditSceneryConsist(); + } + + /// The strip takes the warehouse consist selected, or goes empty without one. + private void EditWarehouseSelection() + { + if (_warehouse.SelectedPreset() is not { } preset) + { + _warehouseEdit.Close(); + _consist.Unload(); + return; + } + + var trainset = _warehouseEdit.Open(preset); + if (ReferenceEquals(_consist.EditingTrainset, trainset)) + RebuildConsist(); + else + _consist.LoadFrom(trainset); + } + + /// Back on the scenery tab, the strip edits the consist that will be started. + private void EditSceneryConsist() + { + _warehouseEdit.Close(); + if (AppServices.Current.State.CurrentTrainset is { } trainset) + _consist.LoadFrom(trainset); + else + _consist.Unload(); + } + + private void ShowSceneryTab() + { + if (WarehouseTabOpen) + consistSourceTabs.SelectedItem = sceneryConsistsTab; + } + + /// From the warehouse's menu: shown where it went, on the scenery tab. + private void ApplyFromWarehouse(Trainset target, List vehicles) + { + ShowSceneryTab(); + ApplyConsist(target, vehicles); + } + + private bool IsSceneryTrainset(Trainset trainset) => + _listScenery?.Trainsets.Contains(trainset) == true; private void CategoryCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) => _browser.CategoryCombo_OnSelectionChanged(sender, e); @@ -169,6 +271,13 @@ public partial class Depot : UserControl private Dynamic NewVehicle(VehicleTexture texture) => _consist.MakeDynamic(texture, _listScenery ?? AppServices.Current.State.CurrentScenery); + /// The scenery files changed under the page in view; out of view it catches up on its way back. + public void FollowSceneryFiles() + { + if (IsVisible) + SyncFromSelection(); + } + private void SyncFromSelection() { @@ -177,25 +286,48 @@ public partial class Depot : UserControl PopulateSceneryConsists(); var trainset = AppServices.Current.State.CurrentTrainset; - if (trainset != null && !ReferenceEquals(trainset, _consist.EditingTrainset)) - _consist.LoadFrom(trainset); - else if (_consist.EditingTrainset != null) + if (WarehouseTabOpen) RebuildConsist(); + else if (trainset is null) + { + // Another scenery, with no consist picked: the old one's is not edited on. + if (_consist.EditingTrainset != null) + _consist.Unload(); + } + else if (!ReferenceEquals(trainset, _consist.EditingTrainset)) + _consist.LoadFrom(trainset); + else + { + ReloadIfEditedElsewhere(); + RebuildConsist(); + } + } + + /// + /// The strip holds the vehicles written back last. A list that no longer holds + /// them was changed on the scenarios page or by a tool, and the next write-back + /// would put the strip's old vehicles over that change. + /// + private void ReloadIfEditedElsewhere() + { + if (_consist.EditingTrainset is { } trainset && !_consist.Flatten().SequenceEqual(trainset.Vehicles)) + _consist.LoadFrom(trainset, _consist.Selected is { } selected ? Math.Max(_consist.IndexOf(selected), 0) : 0); } public void RebuildAfterLanguageChange() { _browser.PopulateCategoryCombo(); PopulateSceneryConsists(); + ReloadIfEditedElsewhere(); RebuildConsist(); _details.Refresh(); } - /// Redraws the consist cards, e.g. after the thumbnail size changed. /// Redraws what depends on the thumbnail size: the consist and the class list. public void RefreshConsistView() { _browser.InitClassComboTemplates(); + ReloadIfEditedElsewhere(); RebuildConsist(); } @@ -217,6 +349,9 @@ public partial class Depot : UserControl } emptyHint.IsVisible = _consist.Count == 0; + emptyHint.Text = _consist.EditingTrainset is not null + ? App.Loc["ConsistEmpty"] + : App.Loc[WarehouseTabOpen ? "WarehousePickHint" : "ConsistPickHint"]; // A selection the consist moved on its own - after a removal, say - is only // highlighted; activating it runs what a click would have run. @@ -226,8 +361,10 @@ public partial class Depot : UserControl // The stats are read off the trainset, so it is written back first. WriteBackToScenery(); + RecordHistory(); _details.Refresh(); UpdateTrainStats(); + UpdateConsistChrome(); // The cards are thrown away and rebuilt on every change, so a keyboard-driven // edit has to be handed its card back or focus escapes the strip. @@ -240,7 +377,154 @@ public partial class Depot : UserControl return; _consist.EditingTrainset.Vehicles = _consist.Flatten(); + if (EditingWarehouse) + { + _warehouseEdit.Save(); + return; + } + RefreshConsistListLabels(); + + // The start button reads the crew off the trainset, which was not written + // back yet when the selection named the vehicle to start in - without this + // it went on saying "no crew" until a vehicle was clicked on the scenarios tab. + if (ReferenceEquals(_consist.EditingTrainset, AppServices.Current.State.CurrentTrainset)) + AppServices.Current.State.NotifyChanged(); + } + + /// + /// The title says which consist the strip edits; the scenery file's tools and + /// "add to warehouse" are for a scenery consist only. + /// + private void UpdateConsistChrome() + { + bool warehouse = EditingWarehouse; + consistGroup.Header = warehouse + ? string.Format(App.Loc["ConsistFromWarehouse"], _warehouseEdit.Name) + : App.Loc["Consist"]; + + var trainset = _consist.EditingTrainset; + bool scenery = trainset is not null && !warehouse && IsSceneryTrainset(trainset); + bool modified = scenery && SceneryTrainsetText.IsModified(trainset!); + bool sceneryTab = !WarehouseTabOpen; + saveToSceneryButton.IsVisible = sceneryTab; + revertSceneryButton.IsVisible = sceneryTab; + addToWarehouseToolButton.IsVisible = sceneryTab; + saveToSceneryButton.IsEnabled = modified; + revertSceneryButton.IsEnabled = modified; + + reversedBadge.IsVisible = trainset?.Reversed == true; + } + + private void SaveToSceneryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + _ = SaveToSceneryAsync(_consist.EditingTrainset); + + private void RevertSceneryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + RevertFromScenery(_consist.EditingTrainset); + + public void KeySaveToScenery() + { + if (!EditingWarehouse) + _ = SaveToSceneryAsync(_consist.EditingTrainset); + } + + /// + /// Writes the consist into the scenery file for good. Asked first only while + /// the file has no backup yet - the first time it is about to change. + /// + private async Task SaveToSceneryAsync(Trainset? trainset) + { + if (trainset is null || _listScenery is not { } scenery || !IsSceneryTrainset(trainset) || + !SceneryTrainsetText.IsModified(trainset) || TopLevel.GetTopLevel(this) is not Window owner) + return; + + // A consist from an included file is saved there - shared, maybe, by other scenarios. + string path = SaveSceneryTrainset.FileOf(scenery, trainset); + string file = trainset.SourceFile ?? Path.GetFileName(scenery.Path); + if (!AppServices.Current.Files.FileExists(path + SaveSceneryTrainset.BackupSuffix) && + !await MessageBox.Show(owner, + string.Format(App.Loc["SaveToSceneryConfirm"], file, file + SaveSceneryTrainset.BackupSuffix), + App.Loc["SaveToScenery"], MessageBoxButtons.YesNo)) + return; + + var (outcome, detail) = AppServices.Current.SaveTrainset.Execute(scenery, trainset); + switch (outcome) + { + case SceneryTrainsetSaveOutcome.NotFound: + await StarterNG.Infrastructure.Diagnostics.ReportAsync( + string.Format(App.Loc["SaveToSceneryNotFound"], file)); + break; + case SceneryTrainsetSaveOutcome.Failed: + await StarterNG.Infrastructure.Diagnostics.ReportAsync( + $"{path}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {detail}"); + break; + } + + RefreshConsistListLabels(); + UpdateConsistChrome(); + } + + /// The consist as its scenery file has it - one more step to undo. + private void RevertFromScenery(Trainset? trainset) + { + if (trainset is null || !IsSceneryTrainset(trainset) || !SceneryTrainsetText.IsModified(trainset)) + return; + + var saved = new Trainset(trainset.RawEntry); + trainset.Vehicles = saved.Vehicles; + trainset.Velocity = saved.Velocity; + if (ReferenceEquals(_consist.EditingTrainset, trainset)) + _consist.LoadFrom(trainset); + else + RefreshConsistListLabels(); + } + + private int SelectedIndex() => + _consist.Selected is { } item ? _consist.IndexOf(item) : 0; + + private void RecordHistory() + { + if (_consist.EditingTrainset is { } trainset) + _history.Record(trainset, SelectedIndex(), rebase: _restoring); + + var editing = _consist.EditingTrainset; + undoButton.IsEnabled = editing is not null && _history.CanUndo(editing); + redoButton.IsEnabled = editing is not null && _history.CanRedo(editing); + } + + public void Undo() => Restore(_history.Undo); + + public void Redo() => Restore(_history.Redo); + + private void Restore(Func step) + { + if (_consist.EditingTrainset is not { } trainset || + step(trainset, SelectedIndex()) is not { } state) + return; + + trainset.Vehicles = state.Vehicles.ToList(); + _restoring = true; + try + { + _consist.LoadFrom(trainset, state.Selected); + } + finally + { + _restoring = false; + } + } + + private void UndoButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Undo(); + + private void RedoButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => Redo(); + + private void ClearConsistButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + KeyClearConsist(); + + private void ReplaceVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (_browser.Selected != null) + ReplaceSelected(_browser.Selected); } private void UpdateTrainStats() @@ -254,15 +538,12 @@ public partial class Depot : UserControl StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields( _consist.EditingTrainset, _info.PhysicsFor, - car => _db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile) is { } t ? VehicleInfo.CategoryOf(t) : null, + _info.CategoryOf, AppServices.Current.LoadWeights.Table.WeightOf)); } - private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (_consist.Selected is { } item) - _consist.Remove(item); - } + private void RemoveVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + KeyRemoveVehicle(); private void ConsistFilter_OnChanged(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { @@ -292,8 +573,8 @@ public partial class Depot : UserControl foreach (var trainset in _listScenery.Trainsets) { - if (IsAiTrainset(trainset) && !showAi) continue; - if (drivableOnly && !UData.StaffedTrain(trainset)) continue; + if (!TrainsetDisplay.ListedIn(trainset, showAi, drivableOnly, AppServices.Current.State.CurrentTrainset)) + continue; var entry = new ListBoxItem { @@ -302,13 +583,29 @@ public partial class Depot : UserControl }; var ts = trainset; + + // The random tools work on the consist being edited, which is not + // necessarily the entry the menu was opened on. + void Edit(Action run) + { + if (!ReferenceEquals(_consist.EditingTrainset, ts)) + sceneryConsistList.SelectedItem = entry; + run(); + } + entry.ContextMenu = ConsistContextMenu.Build( entry, () => ts, vehicles => ApplyConsist(ts, vehicles), () => RemoveAllVehicles(ts), - randomOrder: ToolRandomOrder, - randomTurn: ToolRandomOrientation); + randomOrder: () => Edit(() => _consist.RandomizeOrder(_rng)), + randomTurn: () => Edit(() => _consist.RandomizeOrientation(_rng)), + randomTextures: () => Edit(() => _ = RunTextureRandomizer()), + removeAllGesture: new KeyGesture(Key.Delete, KeyModifiers.Control), + sceneryFile: new ConsistContextMenu.SceneryFileActions( + () => SceneryTrainsetText.IsModified(ts), + () => _ = SaveToSceneryAsync(ts), + () => RevertFromScenery(ts))); sceneryConsistList.Items.Add(entry); if (ReferenceEquals(trainset, AppServices.Current.State.CurrentTrainset)) @@ -330,10 +627,6 @@ public partial class Depot : UserControl _consist.LoadFrom(trainset); } - private static bool IsAiTrainset(Trainset trainset) => - !string.IsNullOrEmpty(trainset.Description) && - trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal); - private void SceneryConsistList_OnKeyDown(object? sender, KeyEventArgs e) { if (e.Key == Key.Delete && e.KeyModifiers.HasFlag(KeyModifiers.Shift) && @@ -348,35 +641,52 @@ public partial class Depot : UserControl private void ApplyConsist(Trainset target, List vehicles) { target.Vehicles = vehicles; - AppServices.Current.State.CurrentScenery = _listScenery; - AppServices.Current.State.CurrentTrainset = target; - _consist.LoadFrom(target); - PopulateSceneryConsists(); + EditTrainset(target); } private void RemoveAllVehicles(Trainset target) { target.Vehicles = new List(); + EditTrainset(target); + } + + /// + /// A scenery consist becomes the one started as well; a warehouse consist is + /// only edited. + /// + private void EditTrainset(Trainset target) + { + if (!IsSceneryTrainset(target)) + { + _consist.LoadFrom(target); + return; + } + AppServices.Current.State.CurrentScenery = _listScenery; AppServices.Current.State.CurrentTrainset = target; _consist.LoadFrom(target); PopulateSceneryConsists(); } - private static Control ConsistListContent(Trainset trainset) => - new TextBlock - { - Text = ConsistLabel(trainset), - TextTrimming = TextTrimming.CharacterEllipsis, - TextWrapping = TextWrapping.NoWrap - }; - - private static string ConsistLabel(Trainset trainset) + /// The composition, after a dot for a consist the scenery file does not have yet. + private static Control ConsistListContent(Trainset trainset) { - string? text = TrainsetDisplay.CompositionText(trainset); - if (text != null) - return text; - return string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? ""); + var label = Scenarios.TrainsetListContent(trainset); + if (!SceneryTrainsetText.IsModified(trainset)) + return label; + + var dot = new TextBlock + { + Text = "●", + Margin = new Thickness(0, 0, 6, 0), + Foreground = new SolidColorBrush(Color.Parse("#E0A030")) + }; + ToolTip.SetTip(dot, App.Loc["ConsistUnsaved"]); + var row = new DockPanel(); + DockPanel.SetDock(dot, Dock.Left); + row.Children.Add(dot); + row.Children.Add(label); + return row; } private void RefreshConsistListLabels() @@ -388,22 +698,6 @@ public partial class Depot : UserControl } } - public async Task RandomizeCurrentTrainsetAsync() - { - if (AppServices.Current.State.CurrentTrainset is not { } trainset) - return; - - if (!ReferenceEquals(trainset, _consist.EditingTrainset)) - { - PopulateSceneryConsists(); - _consist.LoadFrom(trainset); - } - await RunTextureRandomizer(); - } - - public void ToolRandomOrder() => RandomOrderButton_OnClick(null, null!); - public void ToolRandomOrientation() => RandomOrientButton_OnClick(null, null!); - private async void RandomTexturesButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => await RunTextureRandomizer(); @@ -464,20 +758,27 @@ public partial class Depot : UserControl private void VehicleListBox_OnDoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e) { - if (_browser.Selected != null) + // On a row only: the list's scrollbar takes double clicks too. + if (_browser.Selected != null && + (e.Source as Visual)?.FindAncestorOfType(includeSelf: true) != null) ReplaceSelected(_browser.Selected); } - private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + private void AddVehicleButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + KeyAddVehicle(); + + /// Keyboard shortcut entry points. See . + public void KeyAddVehicle() { if (_browser.Selected != null) AddTexture(_browser.Selected); } - /// Keyboard shortcut entry points. See . - public void KeyAddVehicle() => AddVehicleButton_OnClick(null, null!); - - public void KeyRemoveVehicle() => RemoveVehicleButton_OnClick(null, null!); + public void KeyRemoveVehicle() + { + if (_consist.Selected is { } item) + _consist.Remove(item); + } public void KeyClearConsist() { @@ -493,36 +794,36 @@ public partial class Depot : UserControl searchBox.SelectAll(); } - public void ToolAddAllCategory() => AddAllCategoryButton_OnClick(null, null!); + // The tools are reached from any page, and this view catches up with the + // selection only when shown - so they do that first, or they would work on the + // scenery it showed last. They are about the scenery, so never on the warehouse's. + private void PrepareForTool() + { + ShowSceneryTab(); + SyncFromSelection(); + } - public void ToolAddUniqueMmd() => AddUniqueMmdButton_OnClick(null, null!); - - public void ToolRemoveAllTrains() => RemoveAllTrainsButton_OnClick(null, null!); - - private async void AddAllCategoryButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + /// What the browser lists, for a tool to add; with nothing listed it says what to pick. + private List TexturesForTool() { var all = _browser.CurrentTextures(); if (all.Count == 0) - { - await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]); - return; - } + _ = StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]); + return all; + } - foreach (var t in all) + public void ToolAddAllCategory() + { + PrepareForTool(); + foreach (var t in TexturesForTool()) InsertTextureAt(t, _consist.Count); } - private async void AddUniqueMmdButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + public void ToolAddUniqueMmd() { - var all = _browser.CurrentTextures(); - if (all.Count == 0) - { - await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["PickCategoryFirst"]); - return; - } - + PrepareForTool(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var t in all) + foreach (var t in TexturesForTool()) { foreach (string model in ModelsOf(t)) { @@ -547,22 +848,34 @@ public partial class Depot : UserControl yield return alias.Model!; } - private async void RemoveAllTrainsButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + public async Task ToolRemoveAllTrainsAsync() { - if (_listScenery is null || TopLevel.GetTopLevel(this) is not Window owner) + PrepareForTool(); + if (TopLevel.GetTopLevel(this) is not Window owner) return; + // Not the first one on the list: a scenery nobody picked is not emptied. + var state = AppServices.Current.State; + if (state.CurrentScenery is not { } scenery) + { + await StarterNG.Infrastructure.Diagnostics.ReportAsync(App.Loc["NoSceneryConsists"]); + return; + } + if (!await MessageBox.Show(owner, App.Loc["RemoveAllTrainsConfirm"], App.Loc["RemoveAllTrains"], MessageBoxButtons.YesNo)) return; - foreach (var ts in _listScenery.Trainsets) - ts.Vehicles = new List(); + foreach (var ts in scenery.Trainsets) + ts.RemoveVehicles(); - if (_consist.EditingTrainset != null) - _consist.LoadFrom(_consist.EditingTrainset); + // As in the old starter nothing stays picked, so a start is the free camera. + state.StartingVehicleName = null; + state.CurrentTrainset = null; + _consist.Unload(); PopulateSceneryConsists(); RebuildConsist(); + state.NotifyChanged(); } private void ReplaceSelected(VehicleTexture texture) @@ -607,7 +920,7 @@ public partial class Depot : UserControl private void AddCopiesAfter(ConsistItem item, int count) { int index = _consist.IndexOf(item); - if (index < 0 || item.Cars.Count == 0 || _db.TextureFor(item.Cars[0].DataFolder, item.Cars[0].SkinFile, item.Cars[0].MmdFile) is not { } texture) + if (index < 0 || item.Cars.Count == 0 || _info.TextureFor(item.Cars[0]) is not { } texture) return; InsertTexturesAt(texture, index + 1, count); @@ -618,6 +931,11 @@ public partial class Depot : UserControl /// The vehicle count times in a row from at - each staffed as added - and one redraw. private void InsertTexturesAt(VehicleTexture texture, int at, int count) { + // With no consist picked the vehicles would be nowhere but on the strip, and + // gone with its next load. The strip's hint says what to pick. + if (_consist.EditingTrainset is null) + return; + var set = _db.ResolveSet(texture); var unit = set ?? new List { texture }; @@ -641,12 +959,21 @@ public partial class Depot : UserControl RebuildConsist(); } - private async void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + private void TextureBaseButton_OnClick(object? sender, Avalonia.Interactivity.RoutedEventArgs e) => + OpenVehicleBase(); + + private async void OpenVehicleBase() { if (TopLevel.GetTopLevel(this) is not Window owner) return; - var win = new TextureBaseWindow(); + var win = new VehicleBaseWindow(); await win.ShowDialog(owner); - if (win.Picked != null) + _browser.SyncHideArchival(); + if (win.Picked is null) + return; + + if (win.PickedToAdd) + AddTexture(win.Picked); + else ReplaceSelected(win.Picked); } } diff --git a/StarterNG/Views/Depot/ConsistDragging.cs b/StarterNG/Views/Depot/ConsistDragging.cs index 04711f2..181e29d 100644 --- a/StarterNG/Views/Depot/ConsistDragging.cs +++ b/StarterNG/Views/Depot/ConsistDragging.cs @@ -39,7 +39,6 @@ public sealed class ConsistDragging private PointerPressedEventArgs? _pendingDragPress; private Point _pendingDragOrigin; private VehicleTexture? _pendingDragTexture; - private int _pendingDragConsistIndex = -1; private ConsistItem? _pendingBrowserSync; private bool _dragSessionActive; @@ -124,14 +123,12 @@ public sealed class ConsistDragging _pendingDragPress = e; _pendingDragOrigin = e.GetPosition(relativeTo); _pendingDragTexture = texture; - _pendingDragConsistIndex = -1; } private void ClearPendingDrag() { _pendingDragPress = null; _pendingDragTexture = null; - _pendingDragConsistIndex = -1; _pendingBrowserSync = null; _dragSessionActive = false; } @@ -152,7 +149,6 @@ public sealed class ConsistDragging var press = _pendingDragPress; var texture = _pendingDragTexture; - int consistIndex = _pendingDragConsistIndex; _pendingBrowserSync = null; _pendingDragPress = null; diff --git a/StarterNG/Views/Depot/CouplingBits.cs b/StarterNG/Views/Depot/CouplingBits.cs index 3876c7d..253163f 100644 --- a/StarterNG/Views/Depot/CouplingBits.cs +++ b/StarterNG/Views/Depot/CouplingBits.cs @@ -1,3 +1,6 @@ +using System; +using StarterNG.Classes; + namespace StarterNG.Views; public static class CouplingBits @@ -6,3 +9,17 @@ public static class CouplingBits { "CplMechanical", "CplBrake", "CplControl", "CplHighVoltage", "CplGangway", "CplAuxPneumatic", "CplHeating", "CplWorkshop" }; } + +public static class CrewChoices +{ + public static readonly (eDriverType Type, string Key)[] All = + { + (eDriverType.Headdriver, "DriverHead"), (eDriverType.Reardriver, "DriverRear"), + (eDriverType.Passenger, "DriverPassenger"), (eDriverType.Nobody, "DriverNobody") + }; + + public static int IndexOf(eDriverType type) => Array.FindIndex(All, choice => choice.Type == type); + + /// The crew in that place of the list; nobody for no place at all, as a list with nothing selected has. + public static eDriverType At(int index) => index >= 0 && index < All.Length ? All[index].Type : eDriverType.Nobody; +} diff --git a/StarterNG/Views/Depot/TextureMenuItems.cs b/StarterNG/Views/Depot/TextureMenuItems.cs new file mode 100644 index 0000000..b54810e --- /dev/null +++ b/StarterNG/Views/Depot/TextureMenuItems.cs @@ -0,0 +1,29 @@ +using System.IO; +using Avalonia.Controls; +using Avalonia.Input.Platform; +using StarterNG.Domain; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Views; + +/// What the lists of liveries offer about a livery itself. +internal static class TextureMenuItems +{ + /// A control of the window, for its clipboard. + public static void Add(ContextMenu menu, Control owner, VehicleTexture texture) + { + menu.AddItem(App.Loc["CopyTextureName"], async () => + { + if (TopLevel.GetTopLevel(owner)?.Clipboard is { } clipboard) + await clipboard.SetTextAsync(Consist.Base(texture.Skinfile)); + }); + menu.AddItem(App.Loc["OpenTextureFolder"], () => Infrastructure.Diagnostics.OpenOrReport(FolderOf(texture))); + } + + private static string FolderOf(VehicleTexture texture) + { + string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic", + texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); + return Directory.Exists(dir) ? dir : Path.Combine(Directory.GetCurrentDirectory(), texture.Directory); + } +} diff --git a/StarterNG/Views/Depot/TextureRandomizerDialog.cs b/StarterNG/Views/Depot/TextureRandomizerDialog.cs index 4550ecb..ec9efd8 100644 --- a/StarterNG/Views/Depot/TextureRandomizerDialog.cs +++ b/StarterNG/Views/Depot/TextureRandomizerDialog.cs @@ -80,28 +80,23 @@ public sealed class TextureRandomizerDialog revDiff.IsCheckedChanged += (_, _) => age.IsEnabled = revDiff.IsChecked == true; vsYear.IsCheckedChanged += (_, _) => year.IsEnabled = vsYear.IsChecked == true; - var win = new Window - { - Title = App.Loc["RandomizeTexturesTitle"], - Width = 420, - SizeToContent = SizeToContent.Height, - CanResize = false, - WindowStartupLocation = WindowStartupLocation.CenterOwner - }; + var win = MessageBox.FixedDialog(App.Loc["RandomizeTexturesTitle"], 420); var rulesBtn = new Button { Content = App.Loc["RandomizeTexturesRules"], HorizontalAlignment = HorizontalAlignment.Stretch, + HorizontalContentAlignment = HorizontalAlignment.Center, Cursor = _hand }; - rulesBtn.Classes.Add("Basic"); + rulesBtn.Classes.Add("Flat"); rulesBtn.Click += async (_, _) => await new RulesWindow().ShowDialog(win); var ok = new Button { Content = App.Loc["RandomizeTexturesApply"], MinWidth = 90, + HorizontalContentAlignment = HorizontalAlignment.Center, Cursor = _hand }; ok.Classes.Add("Accent"); @@ -114,7 +109,13 @@ public sealed class TextureRandomizerDialog win.Close(true); }; - var cancel = new Button { Content = App.Loc["Cancel"], MinWidth = 90, Cursor = _hand }; + var cancel = new Button + { + Content = App.Loc["Cancel"], + MinWidth = 90, + HorizontalContentAlignment = HorizontalAlignment.Center, + Cursor = _hand + }; cancel.Classes.Add("Flat"); cancel.Click += (_, _) => win.Close(false); @@ -152,13 +153,7 @@ public sealed class TextureRandomizerDialog }, noArch, rulesBtn, - new StackPanel - { - Orientation = Orientation.Horizontal, - HorizontalAlignment = HorizontalAlignment.Right, - Spacing = 8, - Children = { ok, cancel } - } + MessageBox.ButtonRow(ok, cancel) } }; return win; diff --git a/StarterNG/Views/Depot/VehicleBrowser.cs b/StarterNG/Views/Depot/VehicleBrowser.cs index b597cfd..31e4ae6 100644 --- a/StarterNG/Views/Depot/VehicleBrowser.cs +++ b/StarterNG/Views/Depot/VehicleBrowser.cs @@ -1,21 +1,17 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Templates; using Avalonia.Input; -using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Media; -using Avalonia.Media.Imaging; using Avalonia.Threading; using Avalonia.VisualTree; using StarterNG.Classes; using StarterNG.Domain; -using StarterNG.Domain.Sceneries; using StarterNG.Domain.Vehicles; using StarterNG.Application; using StarterNG.Controls; @@ -45,7 +41,7 @@ public sealed class VehicleBrowser _suppress = true; hideArchivalCheck.IsChecked = hideArchival; _suppress = false; - Rebuild(); + PopulateCategoryCombo(); } public VehicleTexture? Selected { get; private set; } @@ -55,6 +51,9 @@ public sealed class VehicleBrowser /// Adds the vehicle to the consist the given number of times. public Action? AddMany { get; set; } + /// Puts the vehicle in place of the one selected in the consist. + public Action? Replace { get; set; } + private bool _suppress; private bool _syncingCombos; @@ -96,7 +95,7 @@ public sealed class VehicleBrowser // In the old starter's order: traction first, then the wagons, then the rest. - private static readonly (string LocKey, Func Match)[] CategoryDefs = + internal static readonly (string LocKey, Func Match)[] CategoryDefs = { ("CatElectricLoco", c => c == "e"), ("CatDieselLoco", c => c == "s"), @@ -202,10 +201,10 @@ public sealed class VehicleBrowser !(AppServices.Current.Settings.HideArchivalVehicles && texture.ResolvedArchived); /// - /// The classes with their thumbnail above the name: in the open list at the size - /// the consist cards use, called again when that size changes; in the closed box - /// at a fixed modest one, so the class picked is seen, as in the old starter, - /// without taking the height the vehicle list needs. + /// The classes with their thumbnail: above the name in the open list, at the size + /// the consist cards use, called again when that size changes; beside it in the + /// closed box at a fixed modest one, so the class picked is seen, as in the old + /// starter, without taking the height the vehicle list needs. /// public void InitClassComboTemplates() { @@ -213,7 +212,7 @@ public sealed class VehicleBrowser classCombo.ItemTemplate = new FuncDataTemplate( (cls, _) => ClassComboContent(cls, height, stacked: true), false); classCombo.SelectionBoxItemTemplate = new FuncDataTemplate( - (cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: true), false); + (cls, _) => ClassComboContent(cls, PickedClassThumbHeight, stacked: false), false); } private const int PickedClassThumbHeight = 30; @@ -363,6 +362,7 @@ public sealed class VehicleBrowser var row = new ListBoxItem { Content = BrowserLabel(texture, _db.ResolveSet(texture)), + HorizontalContentAlignment = HorizontalAlignment.Stretch, Tag = texture }; vehicleListBox.Items.Add(row); @@ -415,60 +415,26 @@ public sealed class VehicleBrowser { var menu = new ContextMenu(); - var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddMany is not null }; - addMany.Click += (_, _) => - AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count)); - menu.Items.Add(addMany); + // What the buttons under the list do, for the vehicle the menu was opened on. + menu.AddItem(App.Loc["AddVehicle"], () => AddMany?.Invoke(texture, 1), AddMany is not null, + new KeyGesture(Key.Insert)); + menu.AddItem(App.Loc["ReplaceVehicle"], () => Replace?.Invoke(texture), Replace is not null); + menu.AddItem(App.Loc["AddMany"], + () => AddManyFlyout.Show(anchor, PlacementMode.Right, count => AddMany?.Invoke(texture, count)), + AddMany is not null); menu.Items.Add(new Separator()); - var copy = new MenuItem { Header = App.Loc["CopyTextureName"] }; - copy.Click += async (_, _) => - { - var top = TopLevel.GetTopLevel(vehicleListBox); - if (top?.Clipboard != null) - await top.Clipboard.SetTextAsync(BrowserName(texture)); - }; - menu.Items.Add(copy); - - var open = new MenuItem { Header = App.Loc["OpenTextureFolder"] }; - open.Click += (_, _) => - { - string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic", - texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); - if (!Directory.Exists(dir)) - dir = Path.Combine(Directory.GetCurrentDirectory(), texture.Directory); - if (!Directory.Exists(dir)) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - string.Format(App.Loc["FaultFileNotFound"], dir)); - return; - } - try - { - System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(dir) - { - UseShellExecute = true - }); - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - $"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}"); - } - }; - menu.Items.Add(open); + TextureMenuItems.Add(menu, vehicleListBox, texture); menu.Items.Add(new Separator()); - var baseItem = new MenuItem { Header = App.Loc["OpenTextureBase"] }; - baseItem.Click += (_, _) => _openTextureBase(); - menu.Items.Add(baseItem); + menu.AddItem(App.Loc["OpenTextureBase"], _openTextureBase); return menu; } /// - /// Fills a detail panel with what is known about a livery: the skin itself, - /// then whatever its credit line carries. + /// Fills a detail panel with what is known about a livery: the model it + /// dresses, then whatever its credit line carries. /// /// /// Built from the same as the general tab, so the @@ -491,7 +457,7 @@ public sealed class VehicleBrowser tooltip.Add($"{label}: {value}"); } - Reading(App.Loc["TextureInfo"], Consist.Base(texture.Skinfile)); + // No line for the skin's name: the general tab and the list both carry it. Reading(App.Loc["Model"], texture.Directory + texture.Model); if (texture.Meta is { } meta) @@ -557,20 +523,35 @@ public sealed class VehicleBrowser // called a vehicle something none of the other panels did. private static string BrowserName(VehicleTexture texture) => Consist.Base(texture.Skinfile); - // The file name alone, as the old starter listed it - the operator is in the - // texture panel - with a faint count for a set of cars. + // The file name, as the old starter listed it, with a faint count for a set + // of cars and the operator at the far end - liveries of one vehicle differ + // mostly by who runs them. private static Control BrowserLabel(VehicleTexture texture, IReadOnlyList? set) { - var name = new TextBlock { Text = BrowserName(texture) }; - if (set is not { Count: > 1 }) - return name; + // Clear of the scroll bar, which lies over the right edge of the list. + var row = new DockPanel { Margin = new Thickness(0, 0, 10, 0) }; - return new StackPanel + string? op = texture.Meta?.Operator; + if (!string.IsNullOrWhiteSpace(op) && op != "?") { - Orientation = Orientation.Horizontal, - Spacing = 6, - Children = { name, new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 } } - }; + var operatorText = new TextBlock + { + Text = op, + Opacity = 0.55, + Margin = new Thickness(8, 0, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis + }; + DockPanel.SetDock(operatorText, Dock.Right); + row.Children.Add(operatorText); + } + + var name = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6 }; + name.Children.Add(new TextBlock { Text = BrowserName(texture) }); + if (set is { Count: > 1 }) + name.Children.Add(new TextBlock { Text = $"×{set.Count}", Opacity = 0.55 }); + row.Children.Add(name); + + return row; } public void SearchBox_OnTextChanged(object? sender, TextChangedEventArgs e) diff --git a/StarterNG/Views/Depot/VehicleCards.cs b/StarterNG/Views/Depot/VehicleCards.cs index 5d9c0ad..cf8929c 100644 --- a/StarterNG/Views/Depot/VehicleCards.cs +++ b/StarterNG/Views/Depot/VehicleCards.cs @@ -36,6 +36,11 @@ public sealed class VehicleCards /// Puts copies of the vehicle on a card right after it, the given number of times. public Action? AddCopies { get; set; } + /// The livery picked in the vehicle browser, offered as the card's replacement. + public Func? BrowserSelection { get; set; } + + public Action? ReplaceWith { get; set; } + private readonly List<(Button Badge, ConsistItem Item)> _driverChrome = new(); public Dynamic? PressedCar { get; set; } @@ -113,9 +118,7 @@ public sealed class VehicleCards if (item.Grouped && item.Cars.Count > 1) controls.Children.Add(SmallButton(MaterialIconKind.CallSplit, App.Loc["TipSplit"], () => _consist.Split(item))); - else if ((index + 1 < _consist.Count && - (Consist.HoldsTail(item) || _consist.CanFormUnit(item, _consist[index + 1]))) || - (index > 0 && Consist.HoldsTail(_consist[index - 1]))) + else if (_consist.CanJoin(item, index)) controls.Children.Add(SmallButton(MaterialIconKind.Link, App.Loc["TipJoin"], () => _consist.Join(item))); var remove = SmallButton(MaterialIconKind.Close, App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item)); @@ -147,6 +150,14 @@ public sealed class VehicleCards card.KeyDown += (_, e) => OnCardKey(card, item, e); card.PointerPressed += (_, e) => { + // The menu is about the card it opened on, so that card is selected - + // without moving the browser, whose pick the menu offers as a replacement. + if (e.GetCurrentPoint(card).Properties.IsRightButtonPressed) + { + Select(item, PressedCar, syncBrowser: false); + PressedCar = null; + return; + } if (!e.GetCurrentPoint(card).Properties.IsLeftButtonPressed) return; @@ -244,7 +255,7 @@ public sealed class VehicleCards /// public void Activate(ConsistItem item) => Select(item, null); - private void Select(ConsistItem item, Dynamic? car) + private void Select(ConsistItem item, Dynamic? car, bool syncBrowser = true) { var target = car ?? (ReferenceEquals(_consist.Selected, item) ? _consist.ActiveCar(item) @@ -258,7 +269,8 @@ public sealed class VehicleCards _consist.SyncStartingVehicle(); RefreshCardSelectionChrome(); _refreshDetails(); - _selectInBrowser(target); + if (syncBrowser) + _selectInBrowser(target); } private Border? CardFor(ConsistItem item) => @@ -335,8 +347,9 @@ public sealed class VehicleCards Redraw(keepFocus: true); break; + // The way a right click does it: the menu fills itself up as it is requested. case Key.Enter: - card.ContextMenu?.Open(card); + card.RaiseEvent(new ContextRequestedEventArgs()); break; default: @@ -612,48 +625,65 @@ public sealed class VehicleCards btn.Foreground = new SolidColorBrush(Color.Parse(color)); } + /// + /// Everything that can be done to one card, the keys that do it alongside: the + /// card's own buttons and keys were the only way to most of it before. + /// private ContextMenu BuildCardMenu(Control anchor, ConsistItem item) { var menu = new ContextMenu(); menu.Opening += (_, _) => { menu.Items.Clear(); + int index = _consist.IndexOf(item); - var addMany = new MenuItem { Header = App.Loc["AddMany"], IsEnabled = AddCopies is not null }; - addMany.Click += (_, _) => - AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count)); - menu.Items.Add(addMany); - menu.Items.Add(new Separator()); - - var num = new MenuItem { Header = App.Loc["WagonNumber"] }; - num.Click += (_, _) => ShowWagonNumberFlyout(anchor, item.Cars[0]); - menu.Items.Add(num); + // Clicking a card shows its own livery in the browser; offering that + // as the replacement would replace the vehicle with itself. + var picked = BrowserSelection?.Invoke(); + var active = _consist.ActiveCar(item); + if (picked is not null && + ReferenceEquals(picked, _db.TextureFor(active.DataFolder, active.SkinFile, active.MmdFile))) + picked = null; + menu.AddItem(picked is null + ? App.Loc["ReplaceWithBrowser"] + : $"{App.Loc["ReplaceWithBrowser"]}: {Consist.Base(picked.Skinfile)}", + () => { if (picked is not null) ReplaceWith?.Invoke(item, picked); }, + picked is not null && ReplaceWith is not null); + menu.AddItem(App.Loc["AddMany"], + () => AddManyFlyout.Show(anchor, PlacementMode.Top, count => AddCopies?.Invoke(item, count)), + AddCopies is not null); menu.Items.Add(new Separator()); + menu.AddItem(App.Loc["TipFlip"], () => { _consist.Flip(item); Redraw(keepFocus: true); }, + gesture: new KeyGesture(Key.Space)); + menu.AddItem(App.Loc["TipMoveLeft"], () => { _consist.MoveLeft(item); Redraw(keepFocus: true); }, + index > 0, new KeyGesture(Key.Left, KeyModifiers.Control)); + menu.AddItem(App.Loc["TipMoveRight"], () => { _consist.MoveRight(item); Redraw(keepFocus: true); }, + index >= 0 && index < _consist.Count - 1, new KeyGesture(Key.Right, KeyModifiers.Control)); - var copyPrev = new MenuItem - { - Header = App.Loc["LoadCopyPrev"], - IsEnabled = _consist.IndexOf(item) > 0 - }; - copyPrev.Click += (_, _) => { _cargo.CopyFromPrevious(_consist, item); _redraw(); }; - menu.Items.Add(copyPrev); + if (item.Grouped && item.Cars.Count > 1) + menu.AddItem(App.Loc["TipSplit"], () => _consist.Split(item)); + else if (_consist.CanJoin(item, index)) + menu.AddItem(App.Loc["TipJoin"], () => _consist.Join(item)); - var copyToAll = new MenuItem - { - Header = App.Loc["LoadCopyToAll"], - IsEnabled = _cargo.IsLoadable(item) - }; - copyToAll.Click += (_, _) => { _cargo.CopyToFollowing(_consist, item); _redraw(); }; - menu.Items.Add(copyToAll); + var crew = new MenuItem { Header = App.Loc["CrewLabel"] }; + foreach (var (type, key) in CrewChoices.All) + crew.AddItem((item.Driver == type ? "● " : "○ ") + App.Loc[key], () => _consist.SetCrew(item, type)); + menu.Items.Add(crew); - var max = new MenuItem - { - Header = App.Loc["LoadMax"], - IsEnabled = _cargo.IsLoadable(item) - }; - max.Click += (_, _) => { _cargo.FillUnit(item); _redraw(); }; - menu.Items.Add(max); + // The member the panels show, not always the first car of a unit. + menu.AddItem(App.Loc["WagonNumber"] + "…", + () => ShowWagonNumberFlyout(anchor, _consist.ActiveCar(item)), + gesture: new KeyGesture(Key.N, KeyModifiers.Control)); + + menu.Items.Add(new Separator()); + menu.AddItem(App.Loc["LoadCopyPrev"], () => { _cargo.CopyFromPrevious(_consist, item); _redraw(); }, index > 0); + menu.AddItem(App.Loc["LoadCopyToAll"], () => { _cargo.CopyToFollowing(_consist, item); _redraw(); }, + _cargo.IsLoadable(item)); + menu.AddItem(App.Loc["LoadMax"], () => { _cargo.FillUnit(item); _redraw(); }, _cargo.IsLoadable(item)); + + menu.Items.Add(new Separator()); + menu.AddItem(App.Loc["RemoveSelectedVehicle"], () => _consist.Remove(item), gesture: new KeyGesture(Key.Delete)); }; return menu; } diff --git a/StarterNG/Views/Depot/VehicleDetails.cs b/StarterNG/Views/Depot/VehicleDetails.cs index c170b7a..b87cb2a 100644 --- a/StarterNG/Views/Depot/VehicleDetails.cs +++ b/StarterNG/Views/Depot/VehicleDetails.cs @@ -1,10 +1,8 @@ using System; -using System.Collections.Generic; using System.Globalization; using System.Linq; using Avalonia; using Avalonia.Controls; -using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; @@ -29,7 +27,6 @@ public sealed class VehicleDetails private readonly StackPanel damagePanel; private readonly Button removeVehicleButton; - private readonly VehicleCatalog _db; private readonly VehicleInfo _info; private readonly Consist _consist; private readonly Cargo _cargo; @@ -39,12 +36,15 @@ public sealed class VehicleDetails private readonly Action _refreshChrome; private readonly Action _showTextureInfo; + /// A setting changed on the cars themselves, which redraws nothing. + public Action? Edited { get; init; } + public VehicleDetails( StackPanel generalPanel, StackPanel consistTexturePanel, StackPanel couplerPanel, Panel couplerActions, StackPanel brakesPanel, StackPanel loadsPanel, StackPanel damagePanel, Button removeVehicleButton, - VehicleCatalog db, VehicleInfo info, Consist consist, Cargo cargo, Cursor hand, + VehicleInfo info, Consist consist, Cargo cargo, Cursor hand, Action redraw, Action refreshChrome, Action showTextureInfo) { this.generalPanel = generalPanel; @@ -56,7 +56,6 @@ public sealed class VehicleDetails this.damagePanel = damagePanel; this.removeVehicleButton = removeVehicleButton; - _db = db; _info = info; _consist = consist; _cargo = cargo; @@ -97,7 +96,7 @@ public sealed class VehicleDetails BuildGeneralInfo(_consist.Selected); - if (_db.TextureFor(_consist.ActiveCar(_consist.Selected).DataFolder, _consist.ActiveCar(_consist.Selected).SkinFile, _consist.ActiveCar(_consist.Selected).MmdFile) is { } tex) + if (_info.TextureFor(_consist.ActiveCar(_consist.Selected)) is { } tex) _showTextureInfo(tex, consistTexturePanel); else consistTexturePanel.Children.Add(PanelNote(App.Loc["NoTextureInfo"])); @@ -269,24 +268,12 @@ public sealed class VehicleDetails private Control BuildVehicleSection(ConsistItem item) { var driver = new ComboBox { FontSize = 12, MinWidth = 0 }; - foreach (string key in new[] { "DriverHead", "DriverRear", "DriverPassenger", "DriverNobody" }) + foreach (var (_, key) in CrewChoices.All) driver.Items.Add(new ComboBoxItem { Content = App.Loc[key] }); - driver.SelectedIndex = item.Driver switch - { - eDriverType.Headdriver => 0, - eDriverType.Reardriver => 1, - eDriverType.Passenger => 2, - _ => 3 - }; + driver.SelectedIndex = CrewChoices.IndexOf(item.Driver); driver.SelectionChanged += (_, _) => { - var picked = driver.SelectedIndex switch - { - 0 => eDriverType.Headdriver, - 1 => eDriverType.Reardriver, - 2 => eDriverType.Passenger, - _ => eDriverType.Nobody - }; + var picked = CrewChoices.At(driver.SelectedIndex); if (picked == item.Driver) return; _consist.SetCrew(item, picked); @@ -338,7 +325,11 @@ public sealed class VehicleDetails IsChecked = d.Coupling.ThermoDynamic, FontSize = 12 }; - thermo.IsCheckedChanged += (_, _) => d.Coupling.ThermoDynamic = thermo.IsChecked == true; + thermo.IsCheckedChanged += (_, _) => + { + d.Coupling.ThermoDynamic = thermo.IsChecked == true; + Edited?.Invoke(); + }; // Label-less row, so the box indents to the control column with everything // else instead of starting at the panel's left edge on its own. @@ -436,6 +427,7 @@ public sealed class VehicleDetails Load = (loadCombo.SelectedItem as ComboBoxItem)?.Tag as string }; foreach (var c in Consist.UnitTargets(item)) c.Coupling.SetBrake(b); + Edited?.Invoke(); } modeCombo.SelectionChanged += (_, _) => Apply(); @@ -501,6 +493,7 @@ public sealed class VehicleDetails }; foreach (var c in _consist.MemberTargets(item)) c.Coupling.SetWheels(ws); + Edited?.Invoke(); } sway.ValueChanged += (_, _) => Apply(); @@ -739,6 +732,7 @@ public sealed class VehicleDetails c.LoadType = string.IsNullOrEmpty(type) ? null : type; c.LoadCount = string.IsNullOrEmpty(type) ? 0 : Math.Min(count, _cargo.LimitFor(c, type)); } + Edited?.Invoke(); } // Seed the pantograph combo from the model before any handler is listening. diff --git a/StarterNG/Views/Depot/Warehouse.cs b/StarterNG/Views/Depot/Warehouse.cs index fd4b577..fb4269b 100644 --- a/StarterNG/Views/Depot/Warehouse.cs +++ b/StarterNG/Views/Depot/Warehouse.cs @@ -11,6 +11,10 @@ using StarterNG.Classes; namespace StarterNG.Views; +/// +/// The warehouse tab: consists kept for later. The one selected is the one the +/// consist strip edits, and the menu puts it into the scenery's selected consist. +/// public sealed class Warehouse { private readonly ListBox _list; @@ -19,6 +23,16 @@ public sealed class Warehouse private readonly Func _target; private readonly Action> _apply; + /// Set while the list is rebuilt, which clears and restores the selection. + private bool _refreshing; + + /// Another consist is selected, or none - not raised mid-rebuild. + public event Action? SelectionChanged; + + /// A consist got a new name: the old one, then the new one. + public event Action? Renamed; + + /// The scenery consist the menu's actions put a warehouse consist into. public Warehouse(ListBox list, Button removeButton, Cursor hand, Func target, Action> apply) { @@ -27,11 +41,20 @@ public sealed class Warehouse _hand = hand; _target = target; _apply = apply; + + _list.SelectionChanged += (_, _) => + { + if (!_refreshing) + SelectionChanged?.Invoke(); + }; } - public void Refresh() + /// The consist to select; the one selected now otherwise. + public void Refresh(string? select = null) { - string? current = SelectedName(); + string? current = select ?? SelectedName(); + string? before = SelectedName(); + _refreshing = true; _list.Items.Clear(); foreach (var preset in PresetStore.All()) @@ -52,10 +75,16 @@ public sealed class Warehouse _list.ContextMenu ??= BuildMenu(); _removeButton.IsEnabled = _list.Items.Count > 0; + _refreshing = false; + + if (!string.Equals(before, SelectedName(), StringComparison.Ordinal)) + SelectionChanged?.Invoke(); } private string? SelectedName() => (_list.SelectedItem as ListBoxItem)?.Tag as string; + public TrainsetPreset? SelectedPreset() => SelectedName() is { } name ? PresetNamed(name) : null; + private static TrainsetPreset? PresetNamed(string name) => PresetStore.All().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.Ordinal)); @@ -68,20 +97,14 @@ public sealed class Warehouse bool hasPreset = SelectedName() != null; bool hasTarget = _target() != null; - void Add(string key, Action run, bool enabled = true) - { - var mi = new MenuItem { Header = App.Loc[key], IsEnabled = enabled }; - mi.Click += (_, _) => run(); - menu.Items.Add(mi); - } - - Add("WarehouseApply", ApplySelected, hasPreset && hasTarget); - Add("WarehouseAppend", AppendSelected, hasPreset && hasTarget); - Add("ChangeName", RenameSelected, hasPreset); + menu.AddItem(App.Loc["WarehouseUseInScenery"], ApplySelected, hasPreset && hasTarget); + menu.AddItem(App.Loc["WarehouseAppendToScenery"], AppendSelected, hasPreset && hasTarget); + menu.AddItem(App.Loc["ChangeName"], RenameSelected, hasPreset); + menu.AddItem(App.Loc["RemoveFromWarehouse"], () => _ = RemoveSelectedAsync(), hasPreset); menu.Items.Add(new Separator()); - Add("PresetImportMagazyn", () => _ = ImportMagazynAsync()); - Add("WarehouseImportFile", () => _ = ImportFileAsync()); + menu.AddItem(App.Loc["PresetImportMagazyn"], () => _ = ImportMagazynAsync()); + menu.AddItem(App.Loc["WarehouseImportFile"], () => _ = ImportFileAsync()); menu.Items.Add(new Separator()); var sort = new MenuItem { Header = App.Loc["PresetSort"] }; @@ -92,68 +115,41 @@ public sealed class Warehouse return menu; } - private MenuItem SortItem(PresetSort mode, string locKey) - { - var item = new MenuItem - { - Header = (PresetStore.SortMode == mode ? "● " : "○ ") + App.Loc[locKey] - }; - item.Click += (_, _) => { PresetStore.SortMode = mode; Refresh(); }; - return item; - } + private MenuItem SortItem(PresetSort mode, string locKey) => + MenuItems.Item((PresetStore.SortMode == mode ? "● " : "○ ") + App.Loc[locKey], + () => { PresetStore.SortMode = mode; Refresh(); }); - public async Task SaveCurrentAsync() - { - if (_target() is not { } trainset) - return; - - string name = trainset.Name; - if (string.IsNullOrWhiteSpace(name)) - name = App.Loc["PresetEmpty"]; - - try - { - PresetStore.Save(name, ConsistText.Serialize(trainset)); - } - catch (Exception ex) - { - await Infrastructure.Diagnostics.ReportAsync(PresetStore.FilePath, ex); - return; - } - Refresh(); - } + /// Asks for a name at the button pressed, and says where the consist went. + public void SaveCurrent(Control anchor) => + ConsistContextMenu.ShowSaveFlyout(anchor, _target(), atPointer: false, saved: name => Refresh(name)); public void ApplySelected() { - if (SelectedName() is not { } name || _target() is not { } target) - return; - - if (PresetNamed(name) is not { } preset) + if (_target() is not { } target || SelectedPreset() is not { } preset) return; if (ConsistText.VehiclesFrom(preset.Entry) is { } vehicles) - _apply(target, vehicles); + _apply(target, ConsistContextMenu.KeepCrew(target, vehicles)); } public void AppendSelected() { - if (SelectedName() is not { } name || _target() is not { } target) + if (_target() is not { } target) return; - var vehicles = ConsistText.VehiclesFrom(PresetNamed(name)?.Entry); + var vehicles = ConsistText.VehiclesFrom(SelectedPreset()?.Entry); if (vehicles is null || vehicles.Count == 0) return; - var merged = new List(target.Vehicles); - merged.AddRange(vehicles); - _apply(target, merged); + _apply(target, Domain.Consist.Joined(target.Vehicles, vehicles)); } public void RenameSelected() { - if (SelectedName() is not { } name || PresetNamed(name) is not { } preset) + if (SelectedPreset() is not { } preset) return; + string name = preset.Name; var nameBox = new TextBox { Text = name, MinWidth = 240 }; var ok = new Button { Content = App.Loc["Ok"], Cursor = _hand }; ok.Classes.Add("Accent"); @@ -169,16 +165,23 @@ public sealed class Warehouse panel.Children.Add(ok); var flyout = new Flyout { Content = panel }; - ok.Click += (_, _) => + ok.Click += async (_, _) => { string? fresh = nameBox.Text?.Trim(); - if (!string.IsNullOrEmpty(fresh) && !string.Equals(fresh, name, StringComparison.Ordinal)) - { - PresetStore.Save(fresh, preset.Entry); - PresetStore.Delete(name); - Refresh(); - } flyout.Hide(); + if (string.IsNullOrEmpty(fresh) || string.Equals(fresh, name, StringComparison.Ordinal)) + return; + + // Differing only in case is this very entry, not another one written over. + if (!string.Equals(fresh, name, StringComparison.OrdinalIgnoreCase) && + !await ConsistContextMenu.MayOverwritePresetAsync(_list, fresh)) + return; + + // Deleted first: a new name differing only in case is the same entry. + PresetStore.Delete(name); + PresetStore.Save(fresh, preset.Entry); + Renamed?.Invoke(name, fresh); + Refresh(fresh); }; flyout.ShowAt(_list); nameBox.Focus(); @@ -214,7 +217,7 @@ public sealed class Warehouse public async Task RemoveSelectedAsync() { - if (SelectedName() is not { } name) + if (SelectedName() is not { } name || !await ConsistContextMenu.MayRemovePresetAsync(_list, name)) return; try diff --git a/StarterNG/Views/MenuItems.cs b/StarterNG/Views/MenuItems.cs new file mode 100644 index 0000000..4e83816 --- /dev/null +++ b/StarterNG/Views/MenuItems.cs @@ -0,0 +1,26 @@ +using System; +using Avalonia.Controls; +using Avalonia.Input; + +namespace StarterNG.Views; + +/// A menu entry that runs something when clicked, as nearly all of them do. +internal static class MenuItems +{ + /// The text, or a control where a row holds more than that. + public static MenuItem Item(object header, Action run, bool enabled = true, KeyGesture? gesture = null) + { + var item = new MenuItem { Header = header, IsEnabled = enabled, InputGesture = gesture }; + item.Click += (_, _) => run(); + return item; + } + + /// The entry at the end of a menu or a submenu. + public static MenuItem AddItem(this ItemsControl menu, object header, Action run, bool enabled = true, + KeyGesture? gesture = null) + { + var item = Item(header, run, enabled, gesture); + menu.Items.Add(item); + return item; + } +} diff --git a/StarterNG/Views/TextureBaseWindow.axaml b/StarterNG/Views/TextureBaseWindow.axaml deleted file mode 100644 index b6bf1d5..0000000 --- a/StarterNG/Views/TextureBaseWindow.axaml +++ /dev/null @@ -1,76 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/StarterNG/Views/TextureBaseWindow.axaml.cs b/StarterNG/Views/TextureBaseWindow.axaml.cs deleted file mode 100644 index c697356..0000000 --- a/StarterNG/Views/TextureBaseWindow.axaml.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Input.Platform; -using Avalonia.Interactivity; -using StarterNG.Classes; -using StarterNG.Domain.Vehicles; -using StarterNG.Application; - -namespace StarterNG.Views; - -public partial class TextureBaseWindow : Window -{ - private readonly VehicleCatalog _db = AppServices.Current.Library.Vehicles; - private readonly List _all = new(); - - public VehicleTexture? Picked { get; private set; } - - public TextureBaseWindow() - { - InitializeComponent(); - - foreach (var t in _db.Textures) - { - if (_db.IsSetFollower(t)) continue; - _all.Add(new TextureRow(t, _db)); - } - - foreach (var box in new Control[] { fName, fOp, fMini, fAuthor, fStation, fPhoto, fModel }) - { - if (box is TextBox tb) - tb.TextChanged += (_, _) => Refresh(); - } - foreach (var box in new Control[] { fNameOn, fOpOn, fMiniOn, fAuthorOn, fStationOn, fPhotoOn, - fModelOn, fRevFromOn, fRevToOn }) - { - if (box is CheckBox cb) - cb.IsCheckedChanged += (_, _) => Refresh(); - } - - fRevFrom.TextChanged += (_, _) => Refresh(); - fRevTo.TextChanged += (_, _) => Refresh(); - - BuildTypeList(); - typeList.SelectionChanged += (_, _) => { BuildModelList(); Refresh(); }; - modelList.SelectionChanged += (_, _) => Refresh(); - - textureList.ContextRequested += TextureList_OnContextRequested; - textureList.AddHandler(PointerReleasedEvent, TextureList_OnPointerReleased, handledEventsToo: true); - Refresh(); - } - - private void BuildModelList() - { - modelList.ItemsSource = null; - - string? cat = (typeList.SelectedItem as ListBoxItem)?.Tag as string; - var rows = _all - .Where(r => cat == null || string.Equals(r.Category, cat, StringComparison.OrdinalIgnoreCase)) - .Where(r => r.Mini.Length > 0) - .GroupBy(r => r.Mini, StringComparer.OrdinalIgnoreCase) - .OrderBy(g => g.Key, StringComparer.OrdinalIgnoreCase) - .Select((g, i) => new TextureModelRow($"{i + 1}.", g.Key, g.First().Directory)) - .ToList(); - - modelList.ItemsSource = rows; - } - - private static DateTime? ParseDate(string? text) => - DateTime.TryParse(text, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out var d) ? d : null; - - private void BuildTypeList() - { - typeList.Items.Add(new ListBoxItem { Content = App.Loc["CatAll"], Tag = null }); - - foreach (string cat in _all.Select(r => r.Category) - .Where(c => c.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(c => c, StringComparer.OrdinalIgnoreCase)) - typeList.Items.Add(new ListBoxItem { Content = cat, Tag = cat }); - - typeList.SelectedIndex = 0; - BuildModelList(); - } - - private void Refresh() - { - IEnumerable q = _all; - - q = Filter(q, fNameOn, fName, r => r.Skin); - q = Filter(q, fOpOn, fOp, r => r.Operator); - q = Filter(q, fMiniOn, fMini, r => r.Mini); - q = Filter(q, fAuthorOn, fAuthor, r => r.Author); - q = Filter(q, fStationOn, fStation, r => r.Station); - q = Filter(q, fPhotoOn, fPhoto, r => r.Photo); - q = Filter(q, fModelOn, fModel, r => r.Model); - - if ((typeList.SelectedItem as ListBoxItem)?.Tag is string cat) - q = q.Where(r => string.Equals(r.Category, cat, StringComparison.OrdinalIgnoreCase)); - - if (modelList.SelectedItem is TextureModelRow mr) - q = q.Where(r => string.Equals(r.Mini, mr.Klasa, StringComparison.OrdinalIgnoreCase)); - - if (fRevFromOn.IsChecked == true && ParseDate(fRevFrom.Text) is { } from) - q = q.Where(r => r.RevisionDate is { } d && d >= from); - if (fRevToOn.IsChecked == true && ParseDate(fRevTo.Text) is { } to) - q = q.Where(r => r.RevisionDate is { } d && d <= to); - - var list = q - .OrderBy(r => r.Mini, StringComparer.OrdinalIgnoreCase) - .ThenBy(r => r.Skin, StringComparer.OrdinalIgnoreCase) - .Take(5000) - .ToList(); - - textureList.ItemsSource = list; - statusText.Text = $"{list.Count} / {_all.Count}"; - } - - private static IEnumerable Filter( - IEnumerable src, CheckBox? on, TextBox? box, Func field) - { - if (on?.IsChecked != true) return src; - string needle = box?.Text?.Trim() ?? ""; - if (needle.Length < 1) return src; - return src.Where(r => field(r).Contains(needle, StringComparison.OrdinalIgnoreCase)); - } - - private void TextureList_OnDoubleTapped(object? sender, TappedEventArgs e) - { - if (textureList.SelectedItem is not TextureRow row) return; - Picked = row.Texture; - } - - // DoubleTapped is raised from the second press, while the list still holds the - // pointer. Closing there leaves the capture on a dead window and on Windows the - // owner stops taking clicks. The release ends the gesture and drops the capture, - // so that is where the window goes. - private void TextureList_OnPointerReleased(object? sender, PointerReleasedEventArgs e) - { - if (Picked != null) - Close(); - } - - private void TextureList_OnContextRequested(object? sender, ContextRequestedEventArgs e) - { - if (textureList.SelectedItem is not TextureRow row) return; - - var menu = new ContextMenu(); - var copy = new MenuItem { Header = App.Loc["CopyTextureName"] }; - copy.Click += async (_, _) => - { - if (Clipboard != null) - await Clipboard.SetTextAsync(row.Skin); - }; - menu.Items.Add(copy); - - var open = new MenuItem { Header = App.Loc["OpenTextureFolder"] }; - open.Click += (_, _) => - { - string dir = Path.Combine(Directory.GetCurrentDirectory(), "dynamic", - row.Texture.Directory.Replace('/', Path.DirectorySeparatorChar).TrimEnd('\\', '/')); - if (!Directory.Exists(dir)) - dir = Path.Combine(Directory.GetCurrentDirectory(), row.Texture.Directory); - if (!Directory.Exists(dir)) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - string.Format(App.Loc["FaultFileNotFound"], dir)); - return; - } - try - { - Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true }); - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - $"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}"); - } - }; - menu.Items.Add(open); - menu.Open(textureList); - e.Handled = true; - } -} - -internal sealed record TextureModelRow(string No, string Klasa, string Sciezka); - -internal sealed class TextureRow -{ - public VehicleTexture Texture { get; } - public string Skin { get; } - public string Mini { get; } - public string Operator { get; } - public string Revision { get; } - public string Author { get; } - public string Station { get; } - public string Photo { get; } - public string Model { get; } - public string Category { get; } - public string Directory { get; } - public DateTime? RevisionDate { get; } - - public TextureRow(VehicleTexture t, VehicleCatalog db) - { - Texture = t; - Skin = Path.GetFileNameWithoutExtension(t.Skinfile); - Mini = db.ResolveMiniName(t) ?? t.ResolvedClass ?? ""; - Operator = t.Meta?.Operator ?? ""; - Revision = t.Meta?.RevisionDate ?? ""; - Author = t.Meta?.TextureAuthor ?? ""; - Station = t.Meta?.Depot ?? ""; - Photo = t.Meta?.PhotoAuthor ?? ""; - Model = t.Model ?? ""; - Category = t.ResolvedCategory ?? ""; - Directory = t.Directory ?? ""; - RevisionDate = DateTime.TryParse(Revision, System.Globalization.CultureInfo.InvariantCulture, - System.Globalization.DateTimeStyles.None, out var rev) ? rev : null; - } -} diff --git a/StarterNG/Views/VehicleBaseQuery.cs b/StarterNG/Views/VehicleBaseQuery.cs new file mode 100644 index 0000000..e2cfc81 --- /dev/null +++ b/StarterNG/Views/VehicleBaseQuery.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using StarterNG.Domain; +using StarterNG.Domain.Vehicles; + +namespace StarterNG.Views; + +public sealed record VehicleClassRow(string Name, int Count); + +public sealed class VehicleBaseRow +{ + public VehicleTexture Texture { get; } + public string Skin { get; } + public string Class { get; } + public string Operator { get; } + public string Revision { get; } + public string Author { get; } + public string Station { get; } + public string Photo { get; } + public string Model { get; } + public string? Category { get; } + public RevisionSpan? Date { get; } + + /// Sorts as text in the order of the dates; empty for a date not known. + public string DateKey => Date?.From.ToString("yyyyMMdd", CultureInfo.InvariantCulture) ?? ""; + + public VehicleBaseRow(VehicleTexture texture) + { + Texture = texture; + Skin = Consist.Base(texture.Skinfile); + Class = VehicleInfo.ClassOf(texture); + Operator = Known(texture.Meta?.Operator); + Revision = Known(texture.Meta?.RevisionDate); + Author = Known(texture.Meta?.TextureAuthor); + Station = Known(texture.Meta?.Depot); + Photo = Known(texture.Meta?.PhotoAuthor); + Model = texture.Directory + texture.Model; + Category = VehicleInfo.CategoryOf(texture); + Date = RevisionSpan.Parse(Revision); + } + + // The credit lines write a question mark for what their author did not know. + private static string Known(string? value) => value is null or "?" ? "" : value; +} + +/// What is typed and picked to look through the vehicle base with. +public sealed record VehicleBaseFilters +{ + public string? Name { get; init; } + public string? Class { get; init; } + public string? Operator { get; init; } + public string? Author { get; init; } + public string? Model { get; init; } + public string? Station { get; init; } + public string? Photo { get; init; } + public string? RevisionFrom { get; init; } + public string? RevisionTo { get; init; } + + /// The class picked from the list, which has to match whole. + public string? PickedClass { get; init; } +} + +public static class VehicleBaseQuery +{ + private static readonly StringComparer Order = StringComparer.CurrentCultureIgnoreCase; + + public static List ClassCounts(IEnumerable rows) => rows + .Where(r => r.Class.Length > 0) + .GroupBy(r => r.Class, StringComparer.OrdinalIgnoreCase) + .Select(g => new VehicleClassRow(g.Key, g.Count())) + .OrderBy(c => c.Name, Order) + .ToList(); + + public static List Select(IEnumerable rows, VehicleBaseFilters filters, + Func sortKey, bool descending) + { + rows = Filter(rows, filters.Name, r => r.Skin); + rows = Filter(rows, filters.Class, r => r.Class); + rows = Filter(rows, filters.Operator, r => r.Operator); + rows = Filter(rows, filters.Author, r => r.Author); + rows = Filter(rows, filters.Model, r => r.Model); + rows = Filter(rows, filters.Station, r => r.Station); + rows = Filter(rows, filters.Photo, r => r.Photo); + + if (filters.PickedClass is { } picked) + rows = rows.Where(r => string.Equals(r.Class, picked, StringComparison.OrdinalIgnoreCase)); + + if (RevisionSpan.Parse(filters.RevisionFrom) is { } from) + rows = rows.Where(r => r.Date is { } date && date.To >= from.From); + if (RevisionSpan.Parse(filters.RevisionTo) is { } to) + rows = rows.Where(r => r.Date is { } date && date.From <= to.To); + + // Blanks stay at the bottom whichever way a column is sorted. + var blanksLast = rows.OrderBy(r => sortKey(r).Length == 0); + var sorted = descending ? blanksLast.ThenByDescending(sortKey, Order) : blanksLast.ThenBy(sortKey, Order); + return sorted.ThenBy(r => r.Skin, Order).ToList(); + } + + private static IEnumerable Filter( + IEnumerable rows, string? typed, Func field) + { + string needle = typed?.Trim() ?? ""; + return needle.Length == 0 + ? rows + : rows.Where(r => field(r).Contains(needle, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/StarterNG/Views/VehicleBaseWindow.axaml b/StarterNG/Views/VehicleBaseWindow.axaml new file mode 100644 index 0000000..e340d07 --- /dev/null +++ b/StarterNG/Views/VehicleBaseWindow.axaml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + - - - - - - + + + + + + + + diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index a827c5a..81c4747 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -3,22 +3,20 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Text; -using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Input; using Avalonia.Interactivity; +using Avalonia.Layout; using Avalonia.Threading; using Avalonia.VisualTree; using StarterNG.Classes; -using StarterNG.Domain; using StarterNG.Infrastructure; -using StarterNG.Services; using StarterNG.Views; using StarterNG.Application; using StarterNG.Application.Abstractions; -using StarterNG.Domain.Sceneries; using StarterNG.Domain.Settings; namespace StarterNG; @@ -28,7 +26,22 @@ public partial class MainWindow : Window private const int TitleBarHeight = 32; + // The smallest window the pages are laid out for, as MainWindow.axaml sets it. + private const double DesignMinWidth = 1010; + private const double DesignMinHeight = 666; + + // The system frame, which a window's size does not count: at most 15 by 8 on Windows. + private const double FrameAllowance = 16; + + // A start is over at once: a double click or a held key arrives after it. + private static readonly TimeSpan StartCooldown = TimeSpan.FromSeconds(3); + private DateTime _openedUtc = DateTime.UtcNow; + private DateTime _startedUtc = DateTime.MinValue; + private bool _starting; + private bool _simulatorRunning; + private bool _lookingAtSceneryFiles; + private readonly DispatcherTimer _sceneryFilesTimer = new() { Interval = TimeSpan.FromSeconds(2) }; public MainWindow() { @@ -38,6 +51,12 @@ public partial class MainWindow : Window ExtendClientAreaToDecorationsHint = true; ExtendClientAreaTitleBarHeightHint = TitleBarHeight; + FitToScreen(); + // Ahead of the layout a new size starts: changed during it, the bar keeps the old width. + Resized += (_, e) => FitTopBar(e.ClientSize.Width); + Opened += (_, _) => FitTopBar(ClientSize.Width); + PageScroll.SizeChanged += (_, _) => KeepPagesUsable(); + if (AppServices.Current.Settings.WindowMaximized || wantsMaximized()) WindowState = WindowState.Maximized; @@ -56,7 +75,6 @@ public partial class MainWindow : Window startButton.AddHandler(PointerReleasedEvent, StartButton_OnRightClick, RoutingStrategies.Tunnel); - SettingsView.DeveloperToolsChanged += ApplyDeveloperTab; SettingsView.ThumbnailSizeChanged += () => { @@ -85,12 +103,108 @@ public partial class MainWindow : Window _openedUtc = DateTime.UtcNow; UpdateStartButton(); + _sceneryFilesTimer.Start(); }; Activated += (_, _) => CheckExternalSettings(); + _sceneryFilesTimer.Tick += async (_, _) => await LookAtSceneryFilesAsync(); + Closed += (_, _) => _sceneryFilesTimer.Stop(); } + /// + /// Scenery files changed outside the starter are picked up without a reload. Not + /// with the simulator running: it is left in peace, and what it changes comes in after it. + /// + private async Task LookAtSceneryFilesAsync() + { + if (_lookingAtSceneryFiles || _simulatorRunning || !IsVisible || WindowState == WindowState.Minimized) + return; + + _lookingAtSceneryFiles = true; + try + { + var services = AppServices.Current; + var held = services.Library.Sceneries.ToList(); + var changes = await Task.Run(() => services.SceneryFiles.Look(held)); + if (changes.Count == 0) + return; + + await ScenariosView.FollowSceneryFilesAsync(changes, mayAsk: IsActive); + DepotView.FollowSceneryFiles(); + } + catch (Exception ex) + { + Diagnostics.Log("Following the scenery files", ex); + } + finally + { + _lookingAtSceneryFiles = false; + } + } + + /// + /// Holds the window to what the screen shows of it, so a small or heavily scaled + /// one (1366x768, or 1080p at 150%) keeps Start above the taskbar. The system lets + /// a window open larger than its screen, and centring one then puts its top off it. + /// + private void FitToScreen() + { + if ((Screens.ScreenFromPoint(Position) ?? Screens.Primary) is not { } screen) + return; + + double width = screen.WorkingArea.Width / screen.Scaling - FrameAllowance; + double height = screen.WorkingArea.Height / screen.Scaling - FrameAllowance; + MinWidth = Math.Min(MinWidth, width); + MinHeight = Math.Min(MinHeight, height); + Width = Math.Min(Width, width); + Height = Math.Min(Height, height); + } + + /// + /// Below the size the pages are laid out for they keep it and scroll, rather than + /// squeeze their controls over each other. Start is outside this and stays in view. + /// + private void KeepPagesUsable() + { + double floorWidth = DesignMinWidth - (ClientSize.Width - PageScroll.Bounds.Width); + double floorHeight = DesignMinHeight - (ClientSize.Height - PageScroll.Bounds.Height); + bool narrow = PageScroll.Bounds.Width < floorWidth; + bool low = PageScroll.Bounds.Height < floorHeight; + + PageHost.Width = narrow ? floorWidth : double.NaN; + PageHost.Height = low ? floorHeight : double.NaN; + PageScroll.HorizontalScrollBarVisibility = narrow ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled; + PageScroll.VerticalScrollBarVisibility = low ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled; + } + + // The version is in the About window too, and the logo is one more link. What fits goes + // by what is there: the version's length depends on the build, the rest on the language. + private void FitTopBar(double width) + { + static double Needed(Control control) + { + control.Measure(Size.Infinity); + return control.DesiredSize.Width; + } + + VersionPanel.IsVisible = true; + LogoLink.IsVisible = true; + double room = width - Needed(TopBarLeft); + if (Needed(TopBarRight) > room) + VersionPanel.IsVisible = false; + if (Needed(TopBarRight) > room) + LogoLink.IsVisible = false; + } + + private void FitTopBarAfterLayout() => + Dispatcher.UIThread.Post(() => FitTopBar(ClientSize.Width), DispatcherPriority.Loaded); + private void TitleBar_OnPointerPressed(object? sender, PointerPressedEventArgs e) { + // A combo box opens on the release and leaves the press to pass on; a window + // move started here would swallow that release. + if (e.Source is Visual source && source.FindAncestorOfType(includeSelf: true) is not null) + return; + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) BeginMoveDrag(e); } @@ -100,18 +214,24 @@ public partial class MainWindow : Window if (e.InitialPressMouseButton != MouseButton.Right) return; - var win = new Views.AdvancedSettingsWindow(); - win.ToolRequested += tool => + OpenLogAndTools(); + e.Handled = true; + } + + public void OpenLogAndTools() + { + var win = new Views.LogAndToolsWindow(); + win.ToolRequested += async tool => { switch (tool) { case "category": DepotView.ToolAddAllCategory(); break; case "mmd": DepotView.ToolAddUniqueMmd(); break; - case "removeall": DepotView.ToolRemoveAllTrains(); break; + case "removeall": await DepotView.ToolRemoveAllTrainsAsync(); break; } + ScenariosView.RefreshConsistView(); }; win.ShowDialog(this); - e.Handled = true; } private void OnShortcutKey(object? sender, KeyEventArgs e) @@ -124,9 +244,12 @@ public partial class MainWindow : Window if (Shortcuts.Match(e.Key, mods) is not { } id) return; - // Bare keys belong to whatever is being typed into; Ctrl/Alt combinations do not - // collide with text entry, so those stay live everywhere. - if (mods == KeyModifiers.None && IsTextEntryFocused()) + // Bare keys belong to whatever is being typed into, and so do undo, redo and + // Ctrl+Delete, which there takes a word and not the whole consist; other + // Ctrl/Alt combinations do not collide with text entry, so those stay live + // everywhere. + if ((mods == KeyModifiers.None || id is Shortcuts.Undo or Shortcuts.Redo or Shortcuts.ClearConsist) && + IsTextEntryFocused()) return; switch (id) @@ -153,6 +276,9 @@ public partial class MainWindow : Window case Shortcuts.RemoveVehicle: DepotView.KeyRemoveVehicle(); break; case Shortcuts.ClearConsist: DepotView.KeyClearConsist(); break; case Shortcuts.SetNumber: DepotView.KeySetWagonNumber(); break; + case Shortcuts.Undo: DepotView.Undo(); break; + case Shortcuts.Redo: DepotView.Redo(); break; + case Shortcuts.SaveToScenery: DepotView.KeySaveToScenery(); break; default: return; } break; @@ -174,7 +300,7 @@ public partial class MainWindow : Window if (sender is not RadioButton { IsChecked: true } rb) return; - if (ScenariosView is null || DepotView is null || SettingsView is null || DeveloperView is null) + if (ScenariosView is null || DepotView is null || SettingsView is null) return; ShowPage(rb.Tag as string); @@ -186,31 +312,12 @@ public partial class MainWindow : Window ScenariosView.IsVisible = page == "scenarios"; DepotView.IsVisible = page == "depot"; SettingsView.IsVisible = page == "settings"; - DeveloperView.IsVisible = page == "developer"; - - if (DeveloperView.IsVisible) - DeveloperView.Refresh(); - } - - /// - /// Shows or hides the developer tab. Switching it off while it is the open one - /// hands the window back to the scenarios, so the top bar never ends up with - /// nothing selected. - /// - private void ApplyDeveloperTab() - { - bool wanted = AppServices.Current.Settings.DeveloperTools; - NavDeveloper.IsVisible = wanted; - - if (!wanted && NavDeveloper.IsChecked == true) - NavScenarios.IsChecked = true; + PageScroll.Offset = default; } private void ApplyInitialNav() { - ApplyDeveloperTab(); - - foreach (var rb in new[] { NavScenarios, NavDepot, NavSettings, NavDeveloper }) + foreach (var rb in new[] { NavScenarios, NavDepot, NavSettings }) if (rb.IsChecked == true) { ShowPage(rb.Tag as string); @@ -222,7 +329,7 @@ public partial class MainWindow : Window private void PopulateLangCombo() { LangCombo.Items.Clear(); - foreach (var lang in LocalizationService.AvailableLanguages()) + foreach (var lang in App.Loc.Languages()) LangCombo.Items.Add(new ComboBoxItem { Content = lang.Name, Tag = lang.Code }); if (LangCombo.ItemCount == 0) LangCombo.Items.Add(new ComboBoxItem { Content = "English", Tag = "en" }); @@ -243,13 +350,8 @@ public partial class MainWindow : Window private void LangCombo_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { - if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is not string code) - return; - - if (string.Equals(code, App.Loc.CurrentLangCode, StringComparison.OrdinalIgnoreCase)) - return; - - App.ApplyLanguage(code); + if ((LangCombo.SelectedItem as ComboBoxItem)?.Tag is string code) + App.ChangeLanguage(code); } private void OnLanguageChanged() @@ -258,33 +360,28 @@ public partial class MainWindow : Window ScenariosView?.RebuildAfterLanguageChange(); DepotView?.RebuildAfterLanguageChange(); UpdateStartButton(); + FitTopBarAfterLayout(); + + // A page not on the screen keeps what it measured: back in view, a tab strip + // in a fixed column went on clipping its new headers at the old words' widths. + foreach (var control in PageHost.GetVisualDescendants().OfType()) + control.InvalidateMeasure(); } private void HelpButton_OnClick(object? sender, RoutedEventArgs e) { var menu = new ContextMenu(); - var keys = new MenuItem - { - Header = App.Loc["ShortcutsTitle"], - InputGesture = new KeyGesture(Key.F1) - }; - keys.Click += (_, _) => Views.ShortcutsWindow.Create().ShowDialog(this); - menu.Items.Add(keys); + menu.AddItem(App.Loc["ShortcutsTitle"], () => Views.ShortcutsWindow.Create().ShowDialog(this), + gesture: new KeyGesture(Key.F1)); menu.Items.Add(new Separator()); - var wiki = new MenuItem { Header = App.Loc["HelpWiki"] }; - wiki.Click += (_, _) => OpenPathOrUrl("https://wiki.eu07.pl/"); - menu.Items.Add(wiki); + menu.AddItem(App.Loc["HelpWiki"], () => OpenPathOrUrl("https://wiki.eu07.pl/")); string readme = string.Equals(App.Loc.CurrentLangCode, "pl", StringComparison.OrdinalIgnoreCase) ? "readme.html" : "en-readme.html"; if (File.Exists(readme) || File.Exists(Path.Combine(Directory.GetCurrentDirectory(), readme))) - { - var mi = new MenuItem { Header = App.Loc["HelpReadme"] }; - mi.Click += (_, _) => OpenPathOrUrl(readme); - menu.Items.Add(mi); - } + menu.AddItem(App.Loc["HelpReadme"], () => OpenPathOrUrl(readme)); string regs = Path.Combine(Directory.GetCurrentDirectory(), "przepisy_kolejowe"); if (Directory.Exists(regs)) @@ -293,12 +390,13 @@ public partial class MainWindow : Window foreach (string pdf in Directory.GetFiles(regs, "*.pdf").OrderBy(Path.GetFileName)) { string file = pdf; - var mi = new MenuItem { Header = Path.GetFileName(file) }; - mi.Click += (_, _) => OpenPathOrUrl(file); - menu.Items.Add(mi); + menu.AddItem(Path.GetFileName(file), () => OpenPathOrUrl(file)); } } + menu.Items.Add(new Separator()); + menu.AddItem(App.Loc["LogAndTools"], OpenLogAndTools); + // "About" belongs with the other informational entries, not next to the // Save/Reset buttons of the settings panel where it used to sit. menu.Items.Add(new Separator()); @@ -355,23 +453,14 @@ public partial class MainWindow : Window } } - private async void openLastLogClick(object? sender, RoutedEventArgs e) - { - string logPath = Path.GetFullPath("log.txt"); - if (!File.Exists(logPath)) - { - await Diagnostics.ReportAsync(string.Format(App.Loc["FaultFileNotFound"], logPath)); - return; - } + private void openLastLogClick(object? sender, RoutedEventArgs e) => + Diagnostics.OpenOrReport(Path.GetFullPath("log.txt")); - try - { - Process.Start(new ProcessStartInfo(logPath) { UseShellExecute = true }); - } - catch (Exception ex) - { - await Diagnostics.ReportAsync(logPath, ex); - } + private void openHaslerTapesClick(object? sender, RoutedEventArgs e) + { + var window = new Views.Tapes.HaslerTapeWindow(); + window.LogEnabled += SettingsView.SyncLogs; + window.Show(this); } private void UpdateStartButton() @@ -380,6 +469,9 @@ public partial class MainWindow : Window var scenery = AppServices.Current.State.CurrentScenery; var trainset = AppServices.Current.State.CurrentTrainset; + startEditorButton.Content = App.Loc[scenery is null ? "StartEditorWizard" : "StartEditorShort"]; + ToolTip.SetTip(startEditorButton, App.Loc[scenery is null ? "StartEditorWizardTip" : "StartEditor"]); + startEditorMoreButton.IsVisible = scenery is not null; if (scenery is null) { @@ -389,8 +481,11 @@ public partial class MainWindow : Window } if (trainset is null) { - startButton.Content = App.Loc["StartNoTrain"]; - startButton.IsEnabled = false; + // Nothing to drive in this scenery - none listed, or none the filters + // show: the free camera is what a start gives. + startButton.Content = App.Loc["StartFreeFly"]; + ToolTip.SetTip(startButton, null); + startButton.IsEnabled = true; return; } @@ -400,26 +495,34 @@ public partial class MainWindow : Window startButton.IsEnabled = true; } - private static bool HasStartableVehicle(Trainset trainset) - { - static bool CanStart(Dynamic v) => - v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; - - string? selected = AppServices.Current.State.StartingVehicleName; - if (!string.IsNullOrEmpty(selected)) - { - var pick = trainset.Vehicles.FirstOrDefault(v => - string.Equals(v.Name, selected, StringComparison.OrdinalIgnoreCase)); - if (pick != null) - return CanStart(pick); - } - return trainset.Vehicles.Any(CanStart); - } + private static bool HasStartableVehicle(Trainset trainset) => + StartSimulation.HasCrewToStart(trainset, AppServices.Current.State.StartingVehicleName); private Task ShowExeProblem(ExeProblem problem, string exe) => MessageBox.Show(this, $"{exe}\n\n{App.Loc[ExeProblemText.Key(problem)]}", App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok); + private const string SimulatorBuildGuide = "https://github.com/MaSzyna-EU07/maszyna#getting-started"; + + /// + /// Tells a user whose processor lacks AVX2 that the published simulator will + /// not run, and where the instructions for a build of their own are. + /// + public async Task ShowCpuNoticeAsync() + { + var (open, silence) = await MessageBox.ShowNotice( + this, string.Format(App.Loc["CpuNoAvx2"], SimulatorBuildGuide), App.Loc["CpuNoAvx2Title"], + App.Loc["CpuNoAvx2Open"], App.Loc["DontShowAgain"]); + + if (open) + OpenPathOrUrl(SimulatorBuildGuide); + if (silence) + { + AppServices.Current.Settings.CpuNotice = false; + AppServices.Current.SettingsStore.Save(); + } + } + /// /// Asks for the simulator when the installation holds none, and keeps the pick /// straight away, so the question is not back on the next start. @@ -450,26 +553,33 @@ public partial class MainWindow : Window if (e.InitialPressMouseButton != MouseButton.Right) return; + OpenStartMenu(startButton); + } + + private void StartMoreButton_OnClick(object? sender, RoutedEventArgs e) => OpenStartMenu(startMoreButton); + + private async void StartEditorButton_OnClick(object? sender, RoutedEventArgs e) => await LaunchEditorAsync(); + + private void StartEditorMoreButton_OnClick(object? sender, RoutedEventArgs e) + { + var create = new MenuItem { Header = App.Loc["StartEditorWizard"] }; + create.Click += async (_, _) => await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteNewSceneryEditor); + var menu = new ContextMenu(); + menu.Items.Add(create); + menu.Open(startEditorMoreButton); + } + + private void OpenStartMenu(Control anchor) + { var menu = new ContextMenu(); - var noSave = new MenuItem { Header = App.Loc["StartWithoutSaving"] }; - noSave.Click += async (_, _) => await LaunchAsync(saveSettings: false, freeFly: false); - menu.Items.Add(noSave); + // Both need a consist to start in, as the start button itself does. + menu.AddItem(App.Loc["StartWithoutSaving"], async () => await LaunchAsync(saveSettings: false, freeFly: false), + startButton.IsEnabled); + menu.AddItem(App.Loc["StartFreeFly"], async () => await LaunchAsync(saveSettings: true, freeFly: true), + startButton.IsEnabled); - var freeFly = new MenuItem { Header = App.Loc["StartFreeFly"] }; - freeFly.Click += async (_, _) => await LaunchAsync(saveSettings: true, freeFly: true); - menu.Items.Add(freeFly); - - menu.Items.Add(new Separator()); - var editor = new MenuItem - { - Header = App.Loc["StartEditor"], - IsEnabled = AppServices.Current.State.CurrentScenery is not null - }; - editor.Click += async (_, _) => await LaunchEditorAsync(); - menu.Items.Add(editor); - - menu.Open(startButton); + menu.Open(anchor); } /// @@ -478,40 +588,56 @@ public partial class MainWindow : Window /// private async Task LaunchAsync(bool saveSettings, bool freeFly) { - var scenery = AppServices.Current.State.CurrentScenery; - var trainset = AppServices.Current.State.CurrentTrainset; - if (scenery is null || trainset is null) + // A second press while the first is still on its way, or right after it, + // would start a second simulator. + if (_starting || DateTime.UtcNow - _startedUtc < StartCooldown) return; - if (!freeFly && !HasStartableVehicle(trainset) && - !await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"], - App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo)) - return; + _starting = true; + try + { + var scenery = AppServices.Current.State.CurrentScenery; + var trainset = AppServices.Current.State.CurrentTrainset; + if (scenery is null) + return; + freeFly |= trainset is null; - LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); + if (!freeFly && trainset != null && !HasStartableVehicle(trainset) && + !await MessageBox.Show(this, App.Loc["StartNoStaffConfirm"], + App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo)) + return; - await FollowStartAsync(AppServices.Current.StartSimulation.Execute(freeFly, saveSettings)); + LoadingScreen.Prepare(trainset?.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); + + await FollowStartAsync(() => AppServices.Current.StartSimulation.Execute(freeFly, saveSettings)); + } + finally + { + _starting = false; + } } /// /// The scenery editor, which only some simulator builds have - said up front, - /// since one without it simply does not open it. + /// since one without it simply does not open it, and it writes to the scenery + /// itself. With none selected there is nothing to write to: the simulator's + /// wizard makes a new one. /// private async Task LaunchEditorAsync() { - if (AppServices.Current.State.CurrentScenery is not { } scenery) - return; - - if (!await MessageBox.Show(this, string.Format(App.Loc["StartEditorWarning"], Path.GetFileName(scenery.Path)), + if (AppServices.Current.State.CurrentScenery is { } scenery && + !await MessageBox.Show(this, string.Format(App.Loc["StartEditorWarning"], Path.GetFileName(scenery.Path)), App.Loc["StartEditor"], MessageBoxButtons.YesNo)) return; - await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteEditor()); + await FollowStartAsync(AppServices.Current.StartSimulation.ExecuteEditor); } /// Reports a start that failed, or steps aside for the simulator that started. - private async Task FollowStartAsync(SimulationStartResult result) + /// The start, run again once what stopped it is put right. + private async Task FollowStartAsync(Func start) { + var result = start(); switch (result.Outcome) { case SimulationStartOutcome.NothingSelected: @@ -533,6 +659,8 @@ public partial class MainWindow : Window return; } + _startedUtc = DateTime.UtcNow; + if (AppServices.Current.Settings.AutoCloseStarter) { Close(); @@ -562,8 +690,10 @@ public partial class MainWindow : Window return; } + _simulatorRunning = true; _ = simulator.WaitForExitAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() => { + _simulatorRunning = false; RestoreFromSimulator(); ReportIfCrashed(simulator, executable, started); }), TaskScheduler.Default); @@ -588,7 +718,7 @@ public partial class MainWindow : Window var words = exit.CleanErrors.Count > 0 ? exit.CleanErrors - : AppServices.Current.Aftermath.SimulatorLogTail(12); + : AppServices.Current.Aftermath.SimulatorLogTail(12, startedUtc); var message = new StringBuilder(); message.AppendLine(string.Format(App.Loc["SimExitCode"], exit.DescribeCode())); diff --git a/StarterNG/Program.cs b/StarterNG/Program.cs index 635bec6..2c1beca 100644 --- a/StarterNG/Program.cs +++ b/StarterNG/Program.cs @@ -1,4 +1,5 @@ using Avalonia; +using Avalonia.Threading; using System; using System.Threading.Tasks; using StarterNG.Application; @@ -50,9 +51,22 @@ static class Program return builder; } + // After setup: the dispatcher takes its platform's implementation when first asked + // for. Not marked handled: a handler that failed half way leaves the state unknown, + // and the exception goes on to end the program as before, only no longer unexplained. + private static void LogUiExceptions() + { + Dispatcher.UIThread.UnhandledException += (_, e) => + { + Diagnostics.Log("Unhandled UI exception", e.Exception); + Diagnostics.Flush(); + }; + } + public static AppBuilder BuildAvaloniaApp() => AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() - .WithDiagnosticsLogging(); + .WithDiagnosticsLogging() + .AfterSetup(_ => LogUiExceptions()); } From 80115b7a7e969f353852e43426ee287205e04cfa Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 16:12:02 +0100 Subject: [PATCH 13/37] Set the start and editor button pairs apart Each button stays close to its arrow, with a clear gap between the two pairs. Both arrows are the same size. --- StarterNG/MainWindow.axaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/StarterNG/MainWindow.axaml b/StarterNG/MainWindow.axaml index f1b8397..38dd3b2 100644 --- a/StarterNG/MainWindow.axaml +++ b/StarterNG/MainWindow.axaml @@ -101,20 +101,20 @@ TabIndex="9" Click="StartButton_OnClick" Content="{Binding [Start], Source={x:Static starterNg:App.Loc}}" /> - - From b56a2a0ae6512815711e07ec7e7729599dbcddc3 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 16:12:02 +0100 Subject: [PATCH 14/37] Raise the version to 1.5 --- StarterNG/StarterNG.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StarterNG/StarterNG.csproj b/StarterNG/StarterNG.csproj index bd5b1d8..d6184ec 100644 --- a/StarterNG/StarterNG.csproj +++ b/StarterNG/StarterNG.csproj @@ -12,7 +12,7 @@ true - 1.4 + 1.5 Starter eu07.pl From 81c7e8ba95018fae45c15c931ef4f8fa1f0d9316 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 16:16:39 +0100 Subject: [PATCH 15/37] Find an old starter's language file on Linux too The list of languages holds codes in lower case, so lang-ZH.txt was not found on a case-sensitive file system and its key descriptions stayed empty. --- StarterNG/Services/LocalizationService.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/StarterNG/Services/LocalizationService.cs b/StarterNG/Services/LocalizationService.cs index 2f83ce9..a7dda79 100644 --- a/StarterNG/Services/LocalizationService.cs +++ b/StarterNG/Services/LocalizationService.cs @@ -275,13 +275,15 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings /// private void ReadOldKeyDescriptions(string code) { - if (string.IsNullOrEmpty(_folders.OldStarter)) + if (string.IsNullOrEmpty(_folders.OldStarter) || !Directory.Exists(_folders.OldStarter)) return; - string path = Path.Combine(_folders.OldStarter, $"lang-{code}.txt"); try { - if (!File.Exists(path)) + // The list holds codes in lower case and the file may be lang-ZH.txt; on Linux those differ. + string? path = Directory.EnumerateFiles(_folders.OldStarter, "lang-*.txt") + .FirstOrDefault(file => string.Equals(Path.GetFileNameWithoutExtension(file), $"lang-{code}", StringComparison.OrdinalIgnoreCase)); + if (path is null) return; bool inDescriptions = false; @@ -304,7 +306,7 @@ public class LocalizationService : INotifyPropertyChanged, ILocalizedStrings } catch (Exception ex) { - _log.Log($"reading {path}", ex); + _log.Log($"reading lang-{code}.txt", ex); } } From 212e11278fcc2fbe586a8a4d96f0e79f5f196d2e Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 18:59:57 +0100 Subject: [PATCH 16/37] Move to Avalonia 12.1.4 12.1.3 stops drawing a stretched PathGeometry, which took the Discord icon, so this goes one patch past the version Dependabot offered. Three things follow from the new version. A combo box placeholder keeps its full opacity. The consist strip's padding, ignored until now, is gone, as it would take 4 px from the panels above. A scenery picked from code is brought into view. --- StarterNG/StarterNG.csproj | 8 ++++---- StarterNG/Styles/Carbon.axaml | 3 +++ StarterNG/Views/Depot.axaml | 3 +-- StarterNG/Views/Scenarios.axaml.cs | 4 ++++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/StarterNG/StarterNG.csproj b/StarterNG/StarterNG.csproj index d6184ec..c24c230 100644 --- a/StarterNG/StarterNG.csproj +++ b/StarterNG/StarterNG.csproj @@ -24,10 +24,10 @@ - - - - + + + + none diff --git a/StarterNG/Styles/Carbon.axaml b/StarterNG/Styles/Carbon.axaml index 736d216..cf9e6a6 100644 --- a/StarterNG/Styles/Carbon.axaml +++ b/StarterNG/Styles/Carbon.axaml @@ -499,6 +499,9 @@ + + + + + + From 96cfe2503e7ba51a184fffa8c096b896308d2843 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 22:32:11 +0100 Subject: [PATCH 22/37] Shorten the experimental renderer's warning It says what can go wrong and what to do then. That the renderer is still being developed helps nobody choosing it. --- StarterNG/startercfg/lang/cz.xml | 2 +- StarterNG/startercfg/lang/en.xml | 2 +- StarterNG/startercfg/lang/hu.xml | 2 +- StarterNG/startercfg/lang/pl.xml | 2 +- StarterNG/startercfg/lang/ru.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/StarterNG/startercfg/lang/cz.xml b/StarterNG/startercfg/lang/cz.xml index 2d17b72..9633316 100644 --- a/StarterNG/startercfg/lang/cz.xml +++ b/StarterNG/startercfg/lang/cz.xml @@ -407,7 +407,7 @@ Grafika Vykreslovací engine Plný funguje s každým exe. Starý, Zjednodušený a Experimentální jen se staršími, nový místo nich spustí Plný. - Upozornění: experimentální renderer je ve vývoji. Může být nestabilní, nezobrazovat části scény nebo se na některých grafických kartách nespustit. Pokud se simulátor nespustí nebo vypadá špatně, vraťte se k rendereru „Plný“. + Upozornění: experimentální renderer může být nestabilní, nezobrazovat části scény nebo se na některých grafických kartách nespustit. Pokud se simulátor nespustí nebo vypadá špatně, vraťte se k rendereru „Plný“. Grafická karta (Linux) Výkonnější (dedikovaná) Podle systému diff --git a/StarterNG/startercfg/lang/en.xml b/StarterNG/startercfg/lang/en.xml index 94e8c0e..391d83c 100644 --- a/StarterNG/startercfg/lang/en.xml +++ b/StarterNG/startercfg/lang/en.xml @@ -414,7 +414,7 @@ Graphics Render engine Full works with every exe. Old, Simplified and Experimental only with older ones; a new exe starts Full instead. - Note: the experimental renderer is still being developed. It may be unstable, miss parts of the scene or not start on some graphics cards. If the simulator does not start or looks wrong, go back to the “Full” renderer. + Note: the experimental renderer may be unstable, miss parts of the scene or not start on some graphics cards. If the simulator does not start or looks wrong, go back to the “Full” renderer. Graphics card (Linux) Faster (discrete) As the system decides diff --git a/StarterNG/startercfg/lang/hu.xml b/StarterNG/startercfg/lang/hu.xml index 5efacc4..cd629fd 100644 --- a/StarterNG/startercfg/lang/hu.xml +++ b/StarterNG/startercfg/lang/hu.xml @@ -407,7 +407,7 @@ Grafika Megjelenítő motor A Teljes minden exe-vel működik. A Régi, az Egyszerűsített és a Kísérleti csak a régebbiekkel, az új helyettük a Teljeset indítja. - Figyelem: a kísérleti renderelő még fejlesztés alatt áll. Instabil lehet, a jelenet egyes részei hiányozhatnak, vagy egyes videokártyákon el sem indul. Ha a szimulátor nem indul el vagy hibásan jelenik meg, válts vissza a „Teljes” renderelőre. + Figyelem: a kísérleti renderelő instabil lehet, a jelenet egyes részei hiányozhatnak, vagy egyes videokártyákon el sem indul. Ha a szimulátor nem indul el vagy hibásan jelenik meg, válts vissza a „Teljes” renderelőre. Videokártya (Linux) Erősebb (dedikált) Ahogy a rendszer dönt diff --git a/StarterNG/startercfg/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml index d06007b..603e93a 100644 --- a/StarterNG/startercfg/lang/pl.xml +++ b/StarterNG/startercfg/lang/pl.xml @@ -414,7 +414,7 @@ Grafika Silnik renderowania Pełny działa z każdym exe. Stary, Uproszczony i Eksperymentalny tylko ze starszymi exe, nowy włączy zamiast nich Pełny. - Uwaga: renderer eksperymentalny jest w trakcie rozwoju. Może działać niestabilnie, nie pokazywać części obiektów albo nie uruchomić się na niektórych kartach graficznych. Gdy symulator nie startuje lub wygląda źle, wróć do renderera „Pełny”. + Uwaga: renderer eksperymentalny może działać niestabilnie, nie pokazywać części obiektów albo nie uruchomić się na niektórych kartach graficznych. Gdy symulator nie startuje lub wygląda źle, wróć do renderera „Pełny”. Karta graficzna (Linux) Wydajniejsza (dedykowana) Jak system diff --git a/StarterNG/startercfg/lang/ru.xml b/StarterNG/startercfg/lang/ru.xml index 78a7208..291f7a1 100644 --- a/StarterNG/startercfg/lang/ru.xml +++ b/StarterNG/startercfg/lang/ru.xml @@ -407,7 +407,7 @@ Графика Движок отрисовки Полный работает с любым exe. Старый, Упрощённый и Экспериментальный только со старыми, новый вместо них запустит Полный. - Внимание: экспериментальный рендерер находится в разработке. Он может работать нестабильно, не показывать части сцены или не запускаться на некоторых видеокартах. Если симулятор не запускается или выглядит неправильно, вернитесь к рендереру «Полный». + Внимание: экспериментальный рендерер может работать нестабильно, не показывать части сцены или не запускаться на некоторых видеокартах. Если симулятор не запускается или выглядит неправильно, вернитесь к рендереру «Полный». Видеокарта (Linux) Мощнее (дискретная) Как решит система From 6d8a6c36e721c8ceb39cf35c98490cd87fff7b0a Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 22:32:11 +0100 Subject: [PATCH 23/37] Put what only the new simulator reads in one panel The rows of keys the public simulator does not read sat among the others in Graphics and Effects, where a player with that exe could not tell them apart. They are now one section at the end of Advanced, with a note that an older exe skips them. Graphics and Effects hold what that exe reads. Buffer scale and anti-aliasing stay in Graphics, as an older exe reads them through other keys. The level list of the atmosphere rows goes with them; the whole levels still set that group. With its rows gone, the nature section is called Smoke. --- StarterNG/Views/Settings.axaml | 385 +++++++++++++++--------------- StarterNG/Views/Settings.axaml.cs | 3 +- StarterNG/startercfg/lang/cz.xml | 5 +- StarterNG/startercfg/lang/en.xml | 5 +- StarterNG/startercfg/lang/hu.xml | 5 +- StarterNG/startercfg/lang/pl.xml | 5 +- StarterNG/startercfg/lang/ru.xml | 5 +- 7 files changed, 210 insertions(+), 203 deletions(-) diff --git a/StarterNG/Views/Settings.axaml b/StarterNG/Views/Settings.axaml index 7e62af8..f6cd705 100644 --- a/StarterNG/Views/Settings.axaml +++ b/StarterNG/Views/Settings.axaml @@ -224,11 +224,6 @@ - - - @@ -244,6 +239,7 @@ + @@ -287,45 +283,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -459,9 +416,6 @@ IsSnapToTickEnabled="True" Value="30" ValueChanged="QualitySlider_OnValueChanged" /> - - - @@ -482,79 +436,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -582,64 +468,11 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -652,19 +485,6 @@ - - - - - - - - - @@ -675,16 +495,6 @@ IsSnapToTickEnabled="True" Value="2" ValueChanged="QualitySlider_OnValueChanged" /> - - - - - - - - - @@ -908,6 +718,199 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StarterNG/Views/Settings.axaml.cs b/StarterNG/Views/Settings.axaml.cs index ee250be..e0d15c6 100644 --- a/StarterNG/Views/Settings.axaml.cs +++ b/StarterNG/Views/Settings.axaml.cs @@ -517,8 +517,7 @@ public partial class Settings : UserControl, ISettingsCapture { (DrawRangeSection, GraphicsGroup.DrawRange), (TexturesSection, GraphicsGroup.Textures), (ShadowsSection, GraphicsGroup.Shadows), (LightsSection, GraphicsGroup.Lights), - (ReflectionsSection, GraphicsGroup.Reflections), (AtmosphereSection, GraphicsGroup.Atmosphere), - (NatureSection, GraphicsGroup.Nature) + (ReflectionsSection, GraphicsGroup.Reflections), (NatureSection, GraphicsGroup.Nature) }) { var levels = new ComboBox(); diff --git a/StarterNG/startercfg/lang/cz.xml b/StarterNG/startercfg/lang/cz.xml index 9633316..82e03a5 100644 --- a/StarterNG/startercfg/lang/cz.xml +++ b/StarterNG/startercfg/lang/cz.xml @@ -900,9 +900,8 @@ Efekty Rozlišení a vyhlazování Osvětlení - Atmosféra a počasí Postprocessing - Vegetace a částice + Kouř Vyhlazování hran Objemová mlha DLSS, generování snímků, Reflex a ray tracing jen s Vulkanem. @@ -943,4 +942,6 @@ OpenGL ES místo OpenGL. Nefunguje s rendererem legacy. {0}, {1} GB {0}, sdílená paměť + Novinky OpenGL 4.6 / Vulkan + Funguje jen s novým exe. Starší exe tato nastavení přeskočí. diff --git a/StarterNG/startercfg/lang/en.xml b/StarterNG/startercfg/lang/en.xml index 391d83c..187376c 100644 --- a/StarterNG/startercfg/lang/en.xml +++ b/StarterNG/startercfg/lang/en.xml @@ -1146,9 +1146,8 @@ Effects Resolution and anti-aliasing Lighting - Atmosphere and weather Post-processing - Vegetation and particles + Smoke Anti-aliasing MSAA smooths the edges of objects, TAA the whole image at the cost of a slight blur. DLSS and DLAA need Vulkan and an NVIDIA RTX card, otherwise the simulator uses TAA. Volumetric fog @@ -1195,4 +1194,6 @@ OpenGL ES instead of OpenGL. Does not work with the legacy renderer. {0}, {1} GB {0}, shared memory + New in OpenGL 4.6 / Vulkan + These work only with the new exe. An older exe skips them. diff --git a/StarterNG/startercfg/lang/hu.xml b/StarterNG/startercfg/lang/hu.xml index cd629fd..997354d 100644 --- a/StarterNG/startercfg/lang/hu.xml +++ b/StarterNG/startercfg/lang/hu.xml @@ -900,9 +900,8 @@ Effektek Felbontás és élsimítás Világítás - Légkör és időjárás Utófeldolgozás - Növényzet és részecskék + Füst Élsimítás Térfogati köd A DLSS, a képkocka-generálás, a Reflex és a ray tracing csak Vulkannal működik. @@ -943,4 +942,6 @@ OpenGL ES az OpenGL helyett. A legacy megjelenítővel nem működik. {0}, {1} GB {0}, megosztott memória + OpenGL 4.6 / Vulkan újdonságok + Csak az új exe-vel működnek. A régebbi exe kihagyja ezeket a beállításokat. diff --git a/StarterNG/startercfg/lang/pl.xml b/StarterNG/startercfg/lang/pl.xml index 603e93a..f8459d6 100644 --- a/StarterNG/startercfg/lang/pl.xml +++ b/StarterNG/startercfg/lang/pl.xml @@ -968,9 +968,8 @@ Efekty Rozdzielczość i wygładzanie Oświetlenie - Atmosfera i pogoda Postprocessing - Roślinność i cząsteczki + Dym Wygładzanie krawędzi MSAA wygładza krawędzie obiektów, TAA cały obraz kosztem lekkiego rozmycia. DLSS i DLAA tylko z Vulkanem na kartach NVIDIA RTX, inaczej symulator użyje TAA. Mgła wolumetryczna @@ -1017,4 +1016,6 @@ OpenGL ES zamiast OpenGL. Nie działa z rendererem legacy. {0}, {1} GB {0}, pamięć wspólna + Nowości OpenGL 4.6 / Vulkan + Działa tylko z nowym exe. Starsze exe pomija te ustawienia. diff --git a/StarterNG/startercfg/lang/ru.xml b/StarterNG/startercfg/lang/ru.xml index 291f7a1..301cd39 100644 --- a/StarterNG/startercfg/lang/ru.xml +++ b/StarterNG/startercfg/lang/ru.xml @@ -900,9 +900,8 @@ Эффекты Разрешение и сглаживание Освещение - Атмосфера и погода Постобработка - Растительность и частицы + Дым Сглаживание Объёмный туман DLSS, генерация кадров, Reflex и трассировка лучей только с Vulkan. @@ -943,4 +942,6 @@ OpenGL ES вместо OpenGL. Не работает с рендерером legacy. {0}, {1} ГБ {0}, общая память + Новое в OpenGL 4.6 / Vulkan + Работает только с новым exe. Старый exe пропускает эти настройки. From 39d4ef0bc4fdce9a2e38539c1952c99b6c7ef60f Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 20:29:18 +0100 Subject: [PATCH 24/37] Read AVX2 from the processor Avx2.IsSupported says whether the starter's own code may use AVX2, and the AOT compiler fixes that at build time to its baseline, which has none. A published starter told every player that their processor lacked AVX2. The check now reads CPUID: AVX2 in leaf 7, AVX and OSXSAVE in leaf 1. --- StarterNG/Infrastructure/CpuFeatures.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/StarterNG/Infrastructure/CpuFeatures.cs b/StarterNG/Infrastructure/CpuFeatures.cs index aa750ef..50c52a8 100644 --- a/StarterNG/Infrastructure/CpuFeatures.cs +++ b/StarterNG/Infrastructure/CpuFeatures.cs @@ -12,5 +12,21 @@ public static class CpuFeatures /// processor. Other architectures cannot run them at all, which is not this. /// public static bool LacksAvx2 => - RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86 && !Avx2.IsSupported; + RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86 && !HasAvx2(); + + /// + /// Asks the processor itself. Avx2.IsSupported only says whether the starter's own + /// code may use AVX2, and the AOT compiler fixes that at build time to its baseline, + /// which has no AVX2, so a published starter always read false. + /// + static bool HasAvx2() + { + if (!X86Base.IsSupported || X86Base.CpuId(0, 0).Eax < 7) + return false; + // Leaf 1 ECX: bit 27 the system saves the extended registers (OSXSAVE), bit 28 AVX. + // Leaf 7 EBX: bit 5 AVX2. + const int osxsaveAndAvx = (1 << 27) | (1 << 28); + return (X86Base.CpuId(1, 0).Ecx & osxsaveAndAvx) == osxsaveAndAvx && + (X86Base.CpuId(7, 0).Ebx & (1 << 5)) != 0; + } } From 16d665156cf75f6181b3ab78f144960e20bc7a6d Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 20:42:28 +0100 Subject: [PATCH 25/37] Take code page 1250 in the trainset save tests from LegacyText The tests asked Encoding for it before anything had registered the code pages. That held only when another test had made an InMemoryFileSystem first, so the class failed when it ran first, as it did on Linux in CI. --- StarterNG.Tests/SceneryTrainsetSaveTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/StarterNG.Tests/SceneryTrainsetSaveTests.cs b/StarterNG.Tests/SceneryTrainsetSaveTests.cs index 1d2aedc..2f18b08 100644 --- a/StarterNG.Tests/SceneryTrainsetSaveTests.cs +++ b/StarterNG.Tests/SceneryTrainsetSaveTests.cs @@ -23,7 +23,9 @@ public class SceneryTrainsetSaveTests "endtrainset\r\n" + "// koniec pliku z polską literą: żółw\r\n"; - private static Encoding Cp1250 => Encoding.GetEncoding(1250); + // The application's own, which registers the code pages first; this class may be + // the first to need them, before any InMemoryFileSystem exists. + private static Encoding Cp1250 => LegacyText.CodePage1250; private static (InMemoryFileSystem Files, Scenery Scenery, SaveSceneryTrainset Save) Setup(string scn) => Setup(Cp1250.GetBytes(scn)); From 32d7417c227bab15a2577d3d10963f2bd5e25935 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 20:42:28 +0100 Subject: [PATCH 26/37] Read the processor once and take FMA into the AVX2 check /arch:AVX2 lets the compiler use FMA as well, so a processor that reports AVX2 without it would still stop the simulator. The comment on OSXSAVE no longer says more than the bit does. --- StarterNG/Infrastructure/CpuFeatures.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/StarterNG/Infrastructure/CpuFeatures.cs b/StarterNG/Infrastructure/CpuFeatures.cs index 50c52a8..e48c809 100644 --- a/StarterNG/Infrastructure/CpuFeatures.cs +++ b/StarterNG/Infrastructure/CpuFeatures.cs @@ -11,7 +11,7 @@ public static class CpuFeatures /// the published builds stop at the first AVX2 instruction on an older x86 /// processor. Other architectures cannot run them at all, which is not this. /// - public static bool LacksAvx2 => + public static bool LacksAvx2 { get; } = RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86 && !HasAvx2(); /// @@ -23,10 +23,11 @@ public static class CpuFeatures { if (!X86Base.IsSupported || X86Base.CpuId(0, 0).Eax < 7) return false; - // Leaf 1 ECX: bit 27 the system saves the extended registers (OSXSAVE), bit 28 AVX. - // Leaf 7 EBX: bit 5 AVX2. - const int osxsaveAndAvx = (1 << 27) | (1 << 28); - return (X86Base.CpuId(1, 0).Ecx & osxsaveAndAvx) == osxsaveAndAvx && + // Leaf 1 ECX: bit 12 FMA, which /arch:AVX2 lets the compiler use too; bit 27 the + // system has turned XSAVE on (whether it saves the AVX registers is in XCR0, + // which .NET cannot read); bit 28 AVX. Leaf 7 EBX: bit 5 AVX2. + const int leaf1 = (1 << 12) | (1 << 27) | (1 << 28); + return (X86Base.CpuId(1, 0).Ecx & leaf1) == leaf1 && (X86Base.CpuId(7, 0).Ebx & (1 << 5)) != 0; } } From a8f8cb0b7441287b37ab7ab525b25217a6ee7a51 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:52 +0100 Subject: [PATCH 27/37] Keep each file's encoding in the copy written for a start The copy carries the scenario includes put in place of their include lines, read as UTF-8 where they are, and it was written all in code page 1250. A name with a Polish letter in a UTF-8 include then no longer matched the same name in the files the simulator reads apart, and what 1250 lacks became '?'. Each part now goes out in the encoding of the file it came from, the scenery's own file too, as a save of a trainset already did. --- StarterNG.Tests/SceneryRepositoryTests.cs | 21 ++++++++++++++++ StarterNG/Application/StartSimulation.cs | 6 ++--- StarterNG/Domain/Sceneries/Scenery.cs | 14 +++++++++++ StarterNG/Domain/Sceneries/SceneryIncludes.cs | 25 +++++++++++++++++++ .../Infrastructure/Adapters/LegacyText.cs | 7 ++++-- .../Sceneries/SceneryRepository.cs | 14 +++++++++-- 6 files changed, 79 insertions(+), 8 deletions(-) diff --git a/StarterNG.Tests/SceneryRepositoryTests.cs b/StarterNG.Tests/SceneryRepositoryTests.cs index facaa7e..08dbfcb 100644 --- a/StarterNG.Tests/SceneryRepositoryTests.cs +++ b/StarterNG.Tests/SceneryRepositoryTests.cs @@ -98,4 +98,25 @@ public class SceneryRepositoryTests Assert.Null(new SceneryImageLocator(files).Resolve(scenery)); } + + [Fact] + public void The_copy_written_for_a_start_keeps_each_file_in_its_own_encoding() + { + const string cars = + "trainset none droga 0 0\r\n" + + "node -1 0 żuk dynamic road\\zuk zuk zuk 0 headdriver 0 0 enddynamic\r\n" + + "endtrainset\r\n"; + var files = new InMemoryFileSystem() + .WithLegacyFile(Path.Combine("game", "scenery", "kato.scn"), + "//$n Łódź\r\nFirstInit\r\ninclude auta.scm end\r\n") + .WithFile(Path.Combine("game", "scenery", "auta.scm"), cars); + var scenery = Assert.Single(Build(files, out _).LoadAll()); + + byte[] exported = scenery.BuildExportBytes(skipDecorTrainsets: false, new StubRandom()); + + static bool Has(byte[] text, byte[] part) => text.AsSpan().IndexOf(part) >= 0; + Assert.True(Has(exported, LegacyText.CodePage1250.GetBytes("//$n Łódź\r\n"))); + Assert.True(Has(exported, Encoding.UTF8.GetBytes("żuk dynamic"))); + Assert.False(Has(exported, Encoding.UTF8.GetBytes("Łódź"))); + } } diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs index 071e5e4..6cacc79 100644 --- a/StarterNG/Application/StartSimulation.cs +++ b/StarterNG/Application/StartSimulation.cs @@ -8,7 +8,6 @@ using StarterNG.Domain; using StarterNG.Domain.Settings; using StarterNG.Domain.Vehicles; using StarterNG.Infrastructure; -using StarterNG.Infrastructure.Adapters; using StarterNG.Infrastructure.Settings; namespace StarterNG.Application; @@ -132,9 +131,8 @@ public sealed class StartSimulation string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName); try { - _files.WriteAllText(exportPath, - scenery.BuildExportContent(_settings.Settings.IgnoreIrrelevantTrains, _random), - LegacyText.CodePage1250); + _files.WriteAllBytes(exportPath, + scenery.BuildExportBytes(_settings.Settings.IgnoreIrrelevantTrains, _random)); } catch (Exception ex) { diff --git a/StarterNG/Domain/Sceneries/Scenery.cs b/StarterNG/Domain/Sceneries/Scenery.cs index 07b0d32..8865bae 100644 --- a/StarterNG/Domain/Sceneries/Scenery.cs +++ b/StarterNG/Domain/Sceneries/Scenery.cs @@ -4,6 +4,7 @@ using System.Text; using System.Text.RegularExpressions; using StarterNG.Application.Abstractions; using StarterNG.Classes; +using StarterNG.Infrastructure.Adapters; namespace StarterNG.Domain.Sceneries; @@ -67,6 +68,12 @@ public sealed class Scenery /// public IReadOnlyDictionary Sources { get; set; } = new Dictionary(); + /// What its own file is written in, for the copy written for a start to be in the same. + public Encoding TextEncoding { get; set; } = LegacyText.CodePage1250; + + /// What each file put in place of its include is written in, by the name its marker carries. + public IReadOnlyDictionary IncludeEncodings { get; set; } = new Dictionary(); + public string DisplayName => System.IO.Path.GetFileNameWithoutExtension(Path); public string LocalizedDescription(ISceneryTranslations translations) => @@ -99,6 +106,13 @@ public sealed class Scenery return result; } + /// + /// as the file to write: each part in the encoding of + /// the file it came from, so the simulator gets every word as that file has it. + /// + public byte[] BuildExportBytes(bool skipDecorTrainsets, IRandomSource random) => + SceneryIncludes.Encode(BuildExportContent(skipDecorTrainsets, random), TextEncoding, IncludeEncodings); + private string AppendLooseVehicles(string text) { var sb = new StringBuilder(); diff --git a/StarterNG/Domain/Sceneries/SceneryIncludes.cs b/StarterNG/Domain/Sceneries/SceneryIncludes.cs index 08181b5..0f11dda 100644 --- a/StarterNG/Domain/Sceneries/SceneryIncludes.cs +++ b/StarterNG/Domain/Sceneries/SceneryIncludes.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; namespace StarterNG.Domain.Sceneries; @@ -75,4 +77,27 @@ public static class SceneryIncludes public static string? FileAt(IReadOnlyList<(int Start, int End, string File)> ranges, int index) => ranges.FirstOrDefault(r => index >= r.Start && index < r.End).File; + + /// + /// The text as bytes: each inlined file in the encoding it was read in, the rest in + /// . A name both a file put in place and one left to its include + /// spell alike then has the same bytes in both, as when the simulator reads them apart. + /// + /// By the name the marker carries; one not listed goes in . + public static byte[] Encode(string text, Encoding own, IReadOnlyDictionary inlined) + { + using var bytes = new MemoryStream(); + void Put(Encoding encoding, int start, int end) => + bytes.Write(encoding.GetBytes(text, start, end - start)); + + int at = 0; + foreach (var (start, end, file) in Ranges(text)) + { + Put(own, at, start); + Put(inlined.GetValueOrDefault(file, own), start, end); + at = end; + } + Put(own, at, text.Length); + return bytes.ToArray(); + } } diff --git a/StarterNG/Infrastructure/Adapters/LegacyText.cs b/StarterNG/Infrastructure/Adapters/LegacyText.cs index b7f5b85..2ef32d9 100644 --- a/StarterNG/Infrastructure/Adapters/LegacyText.cs +++ b/StarterNG/Infrastructure/Adapters/LegacyText.cs @@ -30,9 +30,12 @@ public static class LegacyText /// most files are 1250, and that was an exception per file on every load. /// A byte order mark is dropped, or it would stick to the first token. /// - public static string Decode(byte[] bytes) + public static string Decode(byte[] bytes) => Decode(bytes, out _); + + /// What the file is written in, for what is made of it to go back in the same. + public static string Decode(byte[] bytes, out Encoding encoding) { - var (encoding, bom) = Detect(bytes); + (encoding, var bom) = Detect(bytes); return encoding.GetString(bytes, bom.Length, bytes.Length - bom.Length); } diff --git a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs index e83d8f7..0aa4008 100644 --- a/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs +++ b/StarterNG/Infrastructure/Sceneries/SceneryRepository.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; using System.Threading; using System.Threading.Tasks; using StarterNG.Application.Abstractions; @@ -73,6 +74,7 @@ public sealed class SceneryRepository : ISceneryRepository // Looked at ahead of the reading: changed meanwhile, it counts as changed. var sources = new Dictionary { [path] = FileStamp.Of(_files, path) }; + var encodings = new Dictionary(StringComparer.Ordinal); // With its own .sbt the scenery's terrain comes from it, and the simulator // passes its terrain includes over without opening them. @@ -87,13 +89,21 @@ public sealed class SceneryRepository : ISceneryRepository string? file = ResolveInclude(name); string full = Path.Combine(_paths.Scenery, file ?? name.Replace('\\', '/')); sources[full] = FileStamp.Of(_files, full); - return file is null ? null : (file, LegacyText.Decode(_files.ReadAllBytes(full))); + if (file is null) + return null; + + string included = LegacyText.Decode(_files.ReadAllBytes(full), out var encoding); + encodings[file] = encoding; + return (file, included); } - string text = SceneryIncludes.Inline(LegacyText.Decode(_files.ReadAllBytes(path)), ReadInclude); + string own = LegacyText.Decode(_files.ReadAllBytes(path), out var ownEncoding); + string text = SceneryIncludes.Inline(own, ReadInclude); var scenery = _parser.Parse(path, text, FindInclude); scenery.HasCompanionTimetable = binaryTerrain; scenery.Sources = sources; + scenery.TextEncoding = ownEncoding; + scenery.IncludeEncodings = encodings; return scenery; } catch (Exception ex) From 2cfb4e4fec90a0846b8e37ba48bb247e16f71279 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:53 +0100 Subject: [PATCH 28/37] Tell the tapes the simulator runs only while the starter watches it The tapes looked for any process named as the chosen exe, every two seconds off the UI thread. Under Wine the process is eu07.exe and never matched, so a run never showed as running and the tape window did not follow the driven vehicle, while a simulator started some other way was taken for the last launch. The starter's own watch of the process it started now says it. --- StarterNG.Tests/StartSimulationTests.cs | 2 -- .../Application/Abstractions/IProcessLauncher.cs | 2 -- StarterNG/Application/AppServices.cs | 3 +-- StarterNG/Application/AppState.cs | 12 ++++++++++++ .../Adapters/SystemProcessLauncher.cs | 16 ---------------- StarterNG/MainWindow.axaml.cs | 7 +++---- 6 files changed, 16 insertions(+), 26 deletions(-) diff --git a/StarterNG.Tests/StartSimulationTests.cs b/StarterNG.Tests/StartSimulationTests.cs index 81d3ece..359c73c 100644 --- a/StarterNG.Tests/StartSimulationTests.cs +++ b/StarterNG.Tests/StartSimulationTests.cs @@ -401,8 +401,6 @@ public class StartSimulationTests public bool OpenInShell(string pathOrUrl) => false; public IProcessHandle? FindRunning(string processName) => null; - - public bool IsRunning(string processName) => false; } private sealed class RunningProcess : IProcessHandle diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs index f93d56b..9131240 100644 --- a/StarterNG/Application/Abstractions/IProcessLauncher.cs +++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs @@ -31,6 +31,4 @@ public interface IProcessLauncher bool OpenInShell(string pathOrUrl); IProcessHandle? FindRunning(string processName); - - bool IsRunning(string processName); } diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index ec064d9..7f7b6ce 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -65,8 +65,7 @@ public sealed class AppServices SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(dlssPlatform: !environment.IsMacOS), Executables); MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, missingAssets, Library, log); Favorites = new FavoriteSceneries(files, log, settingsPaths.FavoritesPath()); - Tapes = new HaslerTapes(files, paths, log, clock, Timetables, - () => Processes.IsRunning(Path.GetFileNameWithoutExtension(SettingsStore.ResolveExecutable()))); + Tapes = new HaslerTapes(files, paths, log, clock, Timetables, () => State.SimulatorRunning); Gpus = new LinuxGpus(files, environment); GraphicsCards = new GraphicsCards(files, environment, Gpus); Displays = new Displays(files, environment); diff --git a/StarterNG/Application/AppState.cs b/StarterNG/Application/AppState.cs index 78ab716..bd5cbba 100644 --- a/StarterNG/Application/AppState.cs +++ b/StarterNG/Application/AppState.cs @@ -46,5 +46,17 @@ public sealed class AppState } } + private volatile bool _simulatorRunning; + + /// + /// The simulator the starter watches is still running: the one it started, not + /// any of the same name. Read off the UI thread too. + /// + public bool SimulatorRunning + { + get => _simulatorRunning; + set => _simulatorRunning = value; + } + public void NotifyChanged() => Changed?.Invoke(); } diff --git a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs index 45cf7b3..99f06df 100644 --- a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs +++ b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs @@ -90,22 +90,6 @@ public sealed class SystemProcessLauncher : IProcessLauncher } } - public bool IsRunning(string processName) - { - try - { - var found = Process.GetProcessesByName(processName); - foreach (var process in found) - process.Dispose(); - return found.Length > 0; - } - catch (Exception ex) - { - _log.Log($"lookup {processName}", ex); - return false; - } - } - private sealed class ProcessHandle : IProcessHandle { private const int KeptErrorLines = 20; diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index 0ab7b58..8d71cd0 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -39,7 +39,6 @@ public partial class MainWindow : Window private DateTime _openedUtc = DateTime.UtcNow; private DateTime _startedUtc = DateTime.MinValue; private bool _starting; - private bool _simulatorRunning; private bool _lookingAtSceneryFiles; private readonly DispatcherTimer _sceneryFilesTimer = new() { Interval = TimeSpan.FromSeconds(2) }; @@ -116,7 +115,7 @@ public partial class MainWindow : Window /// private async Task LookAtSceneryFilesAsync() { - if (_lookingAtSceneryFiles || _simulatorRunning || !IsVisible || WindowState == WindowState.Minimized) + if (_lookingAtSceneryFiles || AppServices.Current.State.SimulatorRunning || !IsVisible || WindowState == WindowState.Minimized) return; _lookingAtSceneryFiles = true; @@ -690,10 +689,10 @@ public partial class MainWindow : Window return; } - _simulatorRunning = true; + AppServices.Current.State.SimulatorRunning = true; _ = simulator.WaitForExitAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() => { - _simulatorRunning = false; + AppServices.Current.State.SimulatorRunning = false; RestoreFromSimulator(); ReportIfCrashed(simulator, executable, started); }), TaskScheduler.Default); From c10d771f00f7802ef975d9d3cb0a5a9d72c38d37 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:53 +0100 Subject: [PATCH 29/37] Save a profile without taking it for a save of eu07.ini A profile went through the save of eu07.ini with another path: what the store holds of the file took the changes the file never got, and the list of changes was empty after it. A profile is now written as an export, from a copy. Like a save, an export is not written from defaults when the settings could not be read. --- StarterNG.Tests/SettingsStoreTests.cs | 17 ++++++++++++++++- StarterNG/Application/SettingsStore.cs | 8 +++++++- StarterNG/Views/Settings.axaml.cs | 6 +++--- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/StarterNG.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs index fd13e4f..c2b0169 100644 --- a/StarterNG.Tests/SettingsStoreTests.cs +++ b/StarterNG.Tests/SettingsStoreTests.cs @@ -1,3 +1,4 @@ +using StarterNG.Application; using StarterNG.Domain.Settings; using StarterNG.Tests.Fakes; @@ -564,11 +565,25 @@ public class SettingsStoreTests var installation = WithHome(files); installation.SettingsStore.Load(); - installation.SettingsStore.SaveTo(profile); + Assert.False(installation.SettingsStore.ExportTo(profile)); Assert.False(files.FileExists(profile)); } + [Fact] + public void A_profile_written_leaves_what_the_users_file_still_lacks_to_be_saved() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "uart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartPort = "COM4"; + + Assert.True(installation.SettingsStore.ExportTo("/home/kolejarz/profile.ini")); + + Assert.Contains(new IniChange("uart", "COM3 57600 0.1", "COM4 57600 0.1"), + installation.SettingsStore.Changes(installation.SettingsStore.Settings)); + Assert.Contains("uart COM3 57600 0.1", installation.Files.ReadAllText(UserIni)); + } + [Fact] public void A_read_that_works_again_lets_the_settings_be_saved() { diff --git a/StarterNG/Application/SettingsStore.cs b/StarterNG/Application/SettingsStore.cs index 16a422a..4755f46 100644 --- a/StarterNG/Application/SettingsStore.cs +++ b/StarterNG/Application/SettingsStore.cs @@ -148,7 +148,7 @@ public sealed class SettingsStore SaveTo(_savePath); } - public void SaveTo(string path) + private void SaveTo(string path) { LastSaveFailed = true; if (LoadFailed) @@ -242,6 +242,12 @@ public sealed class SettingsStore /// public bool ExportTo(string path) { + if (LoadFailed) + { + _log.Log($"{path} not written: the settings could not be read, what is held is defaults"); + return false; + } + var copy = ConfigFile.Parse(_config.ToText()); ApplyChanges(copy, Changes(Settings)); diff --git a/StarterNG/Views/Settings.axaml.cs b/StarterNG/Views/Settings.axaml.cs index e0d15c6..aecbad4 100644 --- a/StarterNG/Views/Settings.axaml.cs +++ b/StarterNG/Views/Settings.axaml.cs @@ -235,13 +235,13 @@ public partial class Settings : UserControl, ISettingsCapture App.Loc["ProfileSaveAs"], MessageBoxButtons.YesNo)) return; - var store = AppServices.Current.SettingsStore; + // Written beside the user's own eu07.ini, which stays as it is. CaptureInto(AppServices.Current.Settings); - store.SaveTo(path); + bool saved = AppServices.Current.SettingsStore.ExportTo(path); PopulateProfileCombo(); int idx = FindComboIndexByContent(ProfileCb, Path.GetFileNameWithoutExtension(path)); if (idx >= 0) ProfileCb.SelectedIndex = idx; - SaveStatus.Text = string.Format(App.Loc[store.LastSaveFailed ? "ProfileNotSaved" : "ProfileSaved"], name); + SaveStatus.Text = string.Format(App.Loc[saved ? "ProfileSaved" : "ProfileNotSaved"], name); } private void PopulateExeCombo() From d95182f17cbb2591ee72eb1e15093fe36a674c48 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:54 +0100 Subject: [PATCH 30/37] List a key taken out of eu07.ini once A uart or uarttune the settings no longer write was found missing twice, as a key of set length and as one the file had, and showed twice among the changes. --- StarterNG.Tests/SettingsStoreTests.cs | 12 ++++++++++++ StarterNG/Application/SettingsStore.cs | 4 +++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/StarterNG.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs index c2b0169..66ce35c 100644 --- a/StarterNG.Tests/SettingsStoreTests.cs +++ b/StarterNG.Tests/SettingsStoreTests.cs @@ -319,6 +319,18 @@ public class SettingsStoreTests Assert.DoesNotContain(lines, l => l.StartsWith("uart ")); } + [Fact] + public void A_disabled_uart_is_one_change() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "uart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartEnabled = false; + + var changes = installation.SettingsStore.Changes(installation.SettingsStore.Settings); + + Assert.Equal(new IniChange("uart", "COM3 57600 0.1", null), Assert.Single(changes, c => c.Key == "uart")); + } + [Fact] public void The_graphics_card_choice_survives_a_round_trip() { diff --git a/StarterNG/Application/SettingsStore.cs b/StarterNG/Application/SettingsStore.cs index 4755f46..f538db1 100644 --- a/StarterNG/Application/SettingsStore.cs +++ b/StarterNG/Application/SettingsStore.cs @@ -289,6 +289,7 @@ public sealed class SettingsStore var now = Render(settings); var shown = _capture?.ShownUnlikeSettings ?? new Dictionary(); var changes = new List(); + var listed = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string key in SettingsSerializer.ObsoleteKeys) Add(key, null); @@ -325,7 +326,8 @@ public sealed class SettingsStore { // To the simulator a key past endconfig is not set yet, whatever it says. string? before = value is not null && _config.IsPastEnd(key) ? null : _config.GetRaw(key); - if (before != value) + // A key gone from the file can be found gone more ways than one. + if (before != value && listed.Add(key)) changes.Add(new IniChange(key, before, value)); } } From 306bdc44df64e2b44909818b488630dc21d66623 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:54 +0100 Subject: [PATCH 31/37] Write the list of launches beside the tapes in one move The tape window reads starter-launch.txt every two seconds. Written in place, the file could be read half written, and a crash during the write lost the launches noted before. --- StarterNG/Application/HaslerTapes.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/StarterNG/Application/HaslerTapes.cs b/StarterNG/Application/HaslerTapes.cs index a68731d..df067c0 100644 --- a/StarterNG/Application/HaslerTapes.cs +++ b/StarterNG/Application/HaslerTapes.cs @@ -102,8 +102,8 @@ public sealed class HaslerTapes noted.Add(launch); _files.CreateDirectory(Folder); - _files.WriteAllText(LaunchFile, string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format())), - Encoding.UTF8); + _files.WriteAllTextAtomic(LaunchFile, + string.Concat(noted.TakeLast(RememberedLaunches).Select(l => l.Format()))); lock (_playerLock) _launches = null; } From 02ea1277fc111d222ddba0c61a5964320da604e6 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:54 +0100 Subject: [PATCH 32/37] Read the last run's log before a start has to A start notes down which vehicles the player drove in the run before, as the log begins anew. With the tape window never opened nothing of a long run's log had been read yet, and Start waited on the whole of it. It is now read off the UI thread when the simulator exits and when the starter opens. --- StarterNG/Application/HaslerTapes.cs | 11 +++++++++++ StarterNG/MainWindow.axaml.cs | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/StarterNG/Application/HaslerTapes.cs b/StarterNG/Application/HaslerTapes.cs index df067c0..3d0b464 100644 --- a/StarterNG/Application/HaslerTapes.cs +++ b/StarterNG/Application/HaslerTapes.cs @@ -113,6 +113,17 @@ public sealed class HaslerTapes } } + /// + /// Reads what the log of the last launch has gained since the last look. A start + /// notes that down before the log begins anew, and after a long run, with nothing + /// read of it yet, it would wait on the whole log. + /// + public void FollowLastRun() + { + if (Launches().LastOrDefault() is { } launch) + PlayerIn(launch); + } + /// /// The launches noted so far, oldest first. Read again only when the file has /// changed: this runs every few seconds next to a running simulator. diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index 8d71cd0..1956dd0 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -103,6 +103,7 @@ public partial class MainWindow : Window _openedUtc = DateTime.UtcNow; UpdateStartButton(); _sceneryFilesTimer.Start(); + FollowLastRun(); }; Activated += (_, _) => CheckExternalSettings(); _sceneryFilesTimer.Tick += async (_, _) => await LookAtSceneryFilesAsync(); @@ -693,11 +694,22 @@ public partial class MainWindow : Window _ = simulator.WaitForExitAsync().ContinueWith(_ => Dispatcher.UIThread.Post(() => { AppServices.Current.State.SimulatorRunning = false; + FollowLastRun(); RestoreFromSimulator(); ReportIfCrashed(simulator, executable, started); }), TaskScheduler.Default); } + /// + /// The player's vehicles in the last run, read from its log off the UI thread + /// while nobody waits: the next start has to have them before the log begins anew. + /// + private static void FollowLastRun() + { + if (AppServices.Current.Settings.PhysicsLog) + _ = Task.Run(AppServices.Current.Tapes.FollowLastRun); + } + /// /// Says why the simulator is gone when it did not end the way a session does: /// an error code, or a close so soon after the start that nobody played - a From cf1ba319c1a3e15cbf72b29c1d09b1172a7657ef Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:54 +0100 Subject: [PATCH 33/37] Leave the settings as they are when they are written Writing the settings put them right for the simulator, DLSS to TAA on OpenGL and the like, in the object given. A preview of the changes or a look at the levels goes through the same write and changed the held settings. A copy is put right now. --- StarterNG/Infrastructure/Settings/SettingsSerializer.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs index 2e87b81..ab66b2f 100644 --- a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs +++ b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs @@ -237,8 +237,13 @@ public sealed class SettingsSerializer "starter.developertools", }; - public void Write(SimulatorSettings s, ConfigFile c) + /// + /// The settings into as the simulator would run with them, + /// which leaves as they are: a preview writes them too. + /// + public void Write(SimulatorSettings settings, ConfigFile c) { + var s = settings.Copy(); GraphicsRules.Normalize(s, _dlssPlatform); c.Set("lang", string.IsNullOrWhiteSpace(s.LanguageCode) From 71c32c5b9a6d330493eb1893e43c9f3206c09834 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:55 +0100 Subject: [PATCH 34/37] Read the old starter's key descriptions in code page 1250 too Its language files are code page 1250 as often as UTF-8. Read as UTF-8 only, Czech and Polish descriptions came out with replacement characters. --- StarterNG.Tests/LocalizationTests.cs | 14 ++++++++++++++ StarterNG/Services/LocalizationService.cs | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/StarterNG.Tests/LocalizationTests.cs b/StarterNG.Tests/LocalizationTests.cs index a5cc623..cdb5b90 100644 --- a/StarterNG.Tests/LocalizationTests.cs +++ b/StarterNG.Tests/LocalizationTests.cs @@ -190,6 +190,20 @@ public class LocalizationTests Assert.Equal(string.Empty, loc.Opt("Cmd.z")); } + [Fact] + public void An_old_starters_key_descriptions_are_read_in_its_own_code_page() + { + using var folders = new Folders(); + File.WriteAllText(Path.Combine(folders.Bundled, "en.xml"), English); + File.WriteAllBytes(Path.Combine(folders.OldStarter, "lang-cz.txt"), LegacyText.CodePage1250.GetBytes( + "[labels]\r\n Date: Tue, 3 Nov 2026 21:51:55 +0100 Subject: [PATCH 35/37] Let the signatures of saved trainsets go with their sceneries They were kept by the trainset's text for the whole session, behind one lock, and every reload added more. They are now kept as long as a trainset holds that text. --- .../Domain/Sceneries/SceneryTrainsetText.cs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs b/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs index aaadef6..b6bc8c3 100644 --- a/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs +++ b/StarterNG/Domain/Sceneries/SceneryTrainsetText.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; using StarterNG.Classes; @@ -163,17 +164,14 @@ public static class SceneryTrainsetText public static bool IsModified(Trainset trainset) => trainset.Parsed && Signature(trainset) != SavedSignature(trainset.RawEntry); - private static readonly Dictionary SavedSignatures = new(); + /// + /// By the text a trainset holds, for as long as one holds it: a scenery read again + /// brings texts of its own, and those of the one it replaces go with it. + /// + private static readonly ConditionalWeakTable SavedSignatures = new(); - private static string SavedSignature(string raw) - { - lock (SavedSignatures) - { - if (!SavedSignatures.TryGetValue(raw, out string? signature)) - SavedSignatures[raw] = signature = Signature(new Trainset(raw)); - return signature; - } - } + private static string SavedSignature(string raw) => + SavedSignatures.GetValue(raw, text => Signature(new Trainset(text))); private static string Signature(Trainset trainset) => trainset.Velocity.ToString(CultureInfo.InvariantCulture) + "\n" + From d68999dfb77e2eadf0c03e254479b774cb4047d3 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:56 +0100 Subject: [PATCH 36/37] Look at each source file of a scenery once The watch of scenery files asked three times per file, whether it is there, its time and its size, every two seconds for every source of every scenery. One look gives all three. --- StarterNG.Tests/Fakes/InMemoryFileSystem.cs | 3 +++ StarterNG/Application/Abstractions/IFileSystem.cs | 3 +++ StarterNG/Domain/Sceneries/FileStamp.cs | 4 +--- StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs | 6 ++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs index e95f021..79108cb 100644 --- a/StarterNG.Tests/Fakes/InMemoryFileSystem.cs +++ b/StarterNG.Tests/Fakes/InMemoryFileSystem.cs @@ -200,6 +200,9 @@ public sealed class InMemoryFileSystem : IFileSystem /// Files added through carry the execute bit. public long GetFileSize(string path) => Bytes(path).LongLength; + public (DateTime LastWriteUtc, long Size)? LastWriteAndSize(string path) => + FileExists(path) ? (GetLastWriteTimeUtc(path), GetFileSize(path)) : null; + public bool IsExecutable(string path) => _executable.Contains(Normalize(path)); public InMemoryFileSystem WithExecutable(string path, string contents = "\u007fELF") diff --git a/StarterNG/Application/Abstractions/IFileSystem.cs b/StarterNG/Application/Abstractions/IFileSystem.cs index 66e8ca3..e04f862 100644 --- a/StarterNG/Application/Abstractions/IFileSystem.cs +++ b/StarterNG/Application/Abstractions/IFileSystem.cs @@ -55,6 +55,9 @@ public interface IFileSystem long GetFileSize(string path); + /// A file's time and size in one look; null when there is no file. + (DateTime LastWriteUtc, long Size)? LastWriteAndSize(string path); + bool IsExecutable(string path); /// Where the symbolic link at points, or null for anything else. diff --git a/StarterNG/Domain/Sceneries/FileStamp.cs b/StarterNG/Domain/Sceneries/FileStamp.cs index 674bca0..f892461 100644 --- a/StarterNG/Domain/Sceneries/FileStamp.cs +++ b/StarterNG/Domain/Sceneries/FileStamp.cs @@ -11,9 +11,7 @@ public readonly record struct FileStamp(DateTime LastWriteUtc, long Size) { try { - return files.FileExists(path) - ? new FileStamp(files.GetLastWriteTimeUtc(path), files.GetFileSize(path)) - : null; + return files.LastWriteAndSize(path) is { } stat ? new FileStamp(stat.LastWriteUtc, stat.Size) : null; } catch (Exception) { diff --git a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs index db9350d..15ae4ee 100644 --- a/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs +++ b/StarterNG/Infrastructure/Adapters/PhysicalFileSystem.cs @@ -110,6 +110,12 @@ public sealed class PhysicalFileSystem : IFileSystem public long GetFileSize(string path) => new FileInfo(path).Length; + public (DateTime LastWriteUtc, long Size)? LastWriteAndSize(string path) + { + var info = new FileInfo(path); + return info.Exists ? (info.LastWriteTimeUtc, info.Length) : null; + } + public bool IsExecutable(string path) { if (OperatingSystem.IsWindows()) From cb092784aee5345aa07d6697cc3244e50d60d577 Mon Sep 17 00:00:00 2001 From: maj00r Date: Tue, 3 Nov 2026 21:51:58 +0100 Subject: [PATCH 37/37] Put the comment of FullPath back on it It stayed above DlssLibraries, which then had two summaries, when that method went in between. --- StarterNG/Infrastructure/Settings/ExecutableLocator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs index 81bbed7..c2e321c 100644 --- a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs +++ b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs @@ -267,7 +267,6 @@ public sealed class ExecutableLocator return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage"; } - /// A path as gives it, which may be relative to the installation. /// Whether the DLSS and frame generation libraries lie next to the simulator: without them it cannot use either. public (bool Dlss, bool FrameGeneration) DlssLibraries(string executable) { @@ -295,6 +294,7 @@ public sealed class ExecutableLocator private bool Has(string directory, string pattern) => _files.GetFiles(directory, pattern).Count > 0; + /// A path as gives it, which may be relative to the installation, made whole. public string FullPath(string path) => Path.IsPathRooted(path) ? path : Path.Combine(_paths.Root, path);