diff --git a/StarterNG.Tests/DisplaysTests.cs b/StarterNG.Tests/DisplaysTests.cs new file mode 100644 index 0000000..18d83d5 --- /dev/null +++ b/StarterNG.Tests/DisplaysTests.cs @@ -0,0 +1,100 @@ +using StarterNG.Infrastructure; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class DisplaysTests +{ + /// A 128-byte block with an 18-byte timing at 54, as a panel's base EDID has it. + private static byte[] BaseBlock(int pixelClock10KHz, int hActive, int hBlank, int vActive, int vBlank, + int extensions = 0) + { + var edid = new byte[128]; + new byte[] { 0, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0 }.CopyTo(edid, 0); + edid[126] = (byte)extensions; + int at = 54; + edid[at] = (byte)pixelClock10KHz; + edid[at + 1] = (byte)(pixelClock10KHz >> 8); + edid[at + 2] = (byte)hActive; + edid[at + 3] = (byte)hBlank; + edid[at + 4] = (byte)((hActive >> 8) << 4 | hBlank >> 8); + edid[at + 5] = (byte)vActive; + edid[at + 6] = (byte)vBlank; + edid[at + 7] = (byte)((vActive >> 8) << 4 | vBlank >> 8); + return edid; + } + + /// A DisplayID 2.0 block with one type VII timing (kHz, sizes less one). + private static byte[] DisplayIdBlock(int pixelClockKHz, int hActive, int hBlank, int vActive, int vBlank) + { + var block = new byte[128]; + block[0] = 0x70; + block[1] = 0x20; + block[2] = 23; // one data block: 3 bytes of header, 20 of timing + block[3] = 2; + int at = 5; + block[at] = 0x22; + block[at + 2] = 20; + int t = at + 3, clock = pixelClockKHz - 1; + block[t] = (byte)clock; + block[t + 1] = (byte)(clock >> 8); + block[t + 2] = (byte)(clock >> 16); + void Put(int offset, int value) + { + block[t + offset] = (byte)(value - 1); + block[t + offset + 1] = (byte)((value - 1) >> 8); + } + Put(4, hActive); + Put(6, hBlank); + Put(12, vActive); + Put(14, vBlank); + return block; + } + + [Fact] + public void A_plain_monitor_gives_its_preferred_timing() + { + // 1920x1080 at 148.5 MHz: the usual 60 Hz. + Assert.Equal(60, Edid.HighestRefreshRate(BaseBlock(14850, 1920, 280, 1080, 45))); + } + + [Fact] + public void A_fast_panel_found_only_in_displayid_is_read_there() + { + // As the eDP panel of a gaming laptop: 60 Hz in the base block, 165 Hz in DisplayID. + var edid = BaseBlock(15394, 1920, 160, 1200, 35, extensions: 1) + .Concat(DisplayIdBlock(423250, 1920, 160, 1200, 33)).ToArray(); + + Assert.Equal(165, Edid.HighestRefreshRate(edid)); + } + + [Fact] + public void Something_too_short_for_an_edid_has_no_rate() + { + Assert.Equal(0, Edid.HighestRefreshRate(new byte[] { 0, 0xFF })); + Assert.Equal(0, Edid.HighestRefreshRate(new byte[128])); + } + + [Fact] + public void Only_connected_screens_are_listed_by_their_connector() + { + var files = new InMemoryFileSystem(caseSensitive: true) + .WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n") + .WithFile("/sys/class/drm/card1-HDMI-A-1/status", "disconnected\n") + .WithFile("/sys/class/drm/card1/device/uevent", "DRIVER=amdgpu\n"); + files.WriteAllBytes("/sys/class/drm/card1-eDP-1/edid", BaseBlock(14850, 1920, 280, 1080, 45)); + files.WriteAllBytes("/sys/class/drm/card1-HDMI-A-1/edid", BaseBlock(14850, 1920, 280, 1080, 45)); + + var displays = new Displays(files, new FakeEnvironment()).List(); + + Assert.Equal(new[] { new Display("eDP-1", 60) }, displays); + } + + [Fact] + public void Off_linux_without_windows_nothing_is_read() + { + var environment = new FakeEnvironment { IsLinux = false, IsMacOS = true }; + + Assert.Empty(new Displays(new InMemoryFileSystem(), environment).List()); + } +} diff --git a/StarterNG.Tests/ExecutableLocatorTests.cs b/StarterNG.Tests/ExecutableLocatorTests.cs index 01f45a1..ccbe4bd 100644 --- a/StarterNG.Tests/ExecutableLocatorTests.cs +++ b/StarterNG.Tests/ExecutableLocatorTests.cs @@ -171,6 +171,65 @@ public class ExecutableLocatorTests Assert.Equal(arguments, programArguments); } + [Fact] + public void What_is_inside_says_what_the_file_is_not_its_name() + { + string windowsBuild = TestInstallation.At("eu07-win"); + string nativeBuild = TestInstallation.At("eu07.exe"); + var installation = WithWine(new InMemoryFileSystem() + .WithFile(windowsBuild, PortableExecutable) + .WithExecutable(nativeBuild, Elf)); + var executables = installation.Executables; + + Assert.True(executables.IsWindowsBuild("eu07-win")); + Assert.Equal(Wine, executables.LaunchCommand(windowsBuild, Array.Empty()).FileName); + Assert.False(executables.IsWindowsBuild("eu07.exe")); + Assert.Equal(nativeBuild, executables.LaunchCommand(nativeBuild, Array.Empty()).FileName); + } + + [Fact] + public void A_script_on_linux_starts_itself() + { + string script = TestInstallation.At("eu07-start"); + var installation = WithWine(new InMemoryFileSystem().WithExecutable(script, "#!/bin/sh\nwine eu07.exe\n")); + + string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.EndsWith("eu07-start", resolved); + Assert.Equal(ExeProblem.None, problem); + Assert.Equal(script, installation.Executables.LaunchCommand(script, Array.Empty()).FileName); + } + + [Theory] + [InlineData("notatki do eu07\n")] + [InlineData("")] + public void On_linux_the_execute_bit_alone_does_not_make_a_program(string inside) + { + // On a Windows partition every file has the bit. + string note = TestInstallation.At("eu07_opis"); + var installation = WithWine(new InMemoryFileSystem() + .WithExecutable(note, inside) + .WithFile(TestInstallation.At("eu07.exe"), PortableExecutable)); + + string resolved = installation.Executables.Resolve(new SimulatorSettings(), out var problem); + + Assert.EndsWith("eu07.exe", resolved); + Assert.Equal(ExeProblem.WrongPlatform, problem); + Assert.Equal(new[] { "eu07.exe" }, installation.Executables.ListCandidates()); + Assert.Equal(ExeProblem.NotAProgram, installation.Executables.Validate(note)); + } + + [Fact] + public void A_mac_binary_is_not_taken_for_a_note() + { + string build = TestInstallation.At("eu07"); + var installation = new TestInstallation(new InMemoryFileSystem().WithExecutable(build, "Ïúíþ")); + installation.Environment.IsLinux = false; + installation.Environment.IsMacOS = true; + + Assert.Equal(ExeProblem.None, installation.Executables.Validate(build)); + } + [Fact] public void Wine_is_never_looked_for_on_windows() { diff --git a/StarterNG.Tests/GpuOffloadTests.cs b/StarterNG.Tests/GpuOffloadTests.cs new file mode 100644 index 0000000..d4fbb9a --- /dev/null +++ b/StarterNG.Tests/GpuOffloadTests.cs @@ -0,0 +1,190 @@ +using StarterNG.Domain.Settings; +using StarterNG.Infrastructure; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class GpuOffloadTests +{ + // The laptop this was written on: an RTX 5060 beside a Radeon 780M that drives the screen. + private static readonly GpuDevice Nvidia = new("0000:01:00.0", "10DE:2D19", "nvidia", BootVga: false, + InternalPanel: true); + private static readonly GpuDevice Radeon780M = new("0000:65:00.0", "1002:1900", "amdgpu", BootVga: true, + InternalPanel: true); + private static readonly GpuDevice RadeonDiscrete = new("0000:03:00.0", "1002:73DF", "amdgpu", BootVga: false); + private static readonly GpuDevice IntelIntegrated = new("0000:00:02.0", "8086:A7A0", "i915", BootVga: true, + InternalPanel: true); + + private static string? Unset(string name) => null; + + private static IReadOnlyDictionary For(GpuPreference preference, params GpuDevice[] gpus) => + Inheriting(Unset, preference, gpus); + + private static IReadOnlyDictionary Inheriting(Func inherited, + GpuPreference preference, + params GpuDevice[] gpus) => + GpuOffload.Target(gpus, preference, inherited) is { } target + ? GpuOffload.EnvironmentFor(target) + : new Dictionary(); + + [Fact] + public void A_proprietary_nvidia_card_gets_prime_render_offload() + { + var environment = For(GpuPreference.Discrete, Nvidia, Radeon780M); + + Assert.Equal("1", environment["__NV_PRIME_RENDER_OFFLOAD"]); + Assert.Equal("nvidia", environment["__GLX_VENDOR_LIBRARY_NAME"]); + Assert.Equal("NVIDIA_only", environment["__VK_LAYER_NV_optimus"]); + // DRI_PRIME on a card Mesa does not drive leaves Mesa on llvmpipe. + Assert.False(environment.ContainsKey("DRI_PRIME")); + } + + [Fact] + public void A_card_mesa_drives_is_picked_by_its_pci_address() + { + var environment = For(GpuPreference.Discrete, IntelIntegrated, RadeonDiscrete); + + Assert.Equal("pci-0000_03_00_0", Assert.Single(environment).Value); + Assert.Equal("DRI_PRIME", environment.Keys.Single()); + } + + [Fact] + public void A_card_on_a_driver_of_unknown_ways_is_left_alone() + { + var odd = Nvidia with { Driver = "vfio-pci" }; + + Assert.Empty(For(GpuPreference.Discrete, odd, Radeon780M)); + } + + [Fact] + public void One_card_leaves_nothing_to_choose() + { + Assert.Empty(For(GpuPreference.Discrete, Radeon780M)); + } + + [Fact] + public void The_system_choice_changes_nothing() + { + Assert.Empty(For(GpuPreference.System, Nvidia, Radeon780M)); + } + + [Theory] + [InlineData("DRI_PRIME")] + [InlineData("__NV_PRIME_RENDER_OFFLOAD")] + [InlineData("__GLX_VENDOR_LIBRARY_NAME")] + public void A_choice_the_player_made_stands(string variable) + { + // prime-run %command% in Steam's launch options, say. + var environment = Inheriting(name => name == variable ? "1" : null, GpuPreference.Discrete, Nvidia, Radeon780M); + + Assert.Empty(environment); + } + + [Fact] + public void A_discrete_card_already_driving_the_screen_needs_no_offload() + { + // A laptop with its MUX switched to the NVIDIA card. + Assert.Empty(For(GpuPreference.Discrete, Nvidia with { BootVga = true }, + Radeon780M with { BootVga = false })); + } + + [Fact] + public void A_desktop_stays_on_the_card_its_monitor_is_plugged_into() + { + // The monitor on a Radeon, the processor's own graphics left on: moving + // off the boot card would land on the weaker one. + var radeon = RadeonDiscrete with { BootVga = true }; + var intel = IntelIntegrated with { BootVga = false, InternalPanel = false }; + + Assert.Empty(For(GpuPreference.Discrete, radeon, intel)); + } + + // ── What the settings say about it ─────────────────────────────────────── + + [Fact] + public void No_card_read_is_said_whatever_was_chosen() + { + Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty(), GpuPreference.Discrete, Unset)); + Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty(), GpuPreference.System, name => "1")); + } + + [Fact] + public void One_card_is_said_whatever_was_chosen() + { + Assert.Equal(GpuNote.OneCard, GpuOffload.NoteFor(new[] { Radeon780M }, GpuPreference.Discrete, Unset)); + Assert.Equal(GpuNote.OneCard, GpuOffload.NoteFor(new[] { Radeon780M }, GpuPreference.System, name => "1")); + } + + [Fact] + public void The_system_choice_comes_before_what_the_player_set() + { + Assert.Equal(GpuNote.LeftToSystem, + GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.System, name => "1")); + } + + [Fact] + public void What_the_player_set_comes_before_the_card_it_would_be_moved_to() + { + static string? Inherited(string name) => name is "DRI_PRIME" or "__NV_PRIME_RENDER_OFFLOAD" ? "1" : null; + + Assert.Equal(GpuNote.PlayerChose, + GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, Inherited)); + Assert.Equal(new[] { "__NV_PRIME_RENDER_OFFLOAD", "DRI_PRIME" }, GpuOffload.PlayerChoices(Inherited)); + } + + [Fact] + public void A_variable_set_empty_is_no_choice_of_the_player() + { + Assert.Empty(GpuOffload.PlayerChoices(name => string.Empty)); + Assert.Equal(GpuNote.Moved, + GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, name => string.Empty)); + } + + [Fact] + public void The_note_says_a_move_only_with_a_card_to_move_to() + { + Assert.Equal(GpuNote.Moved, GpuOffload.NoteFor(new[] { Nvidia, Radeon780M }, GpuPreference.Discrete, Unset)); + Assert.Equal(GpuNote.NoTarget, + GpuOffload.NoteFor(new[] { Nvidia with { Driver = "vfio-pci" }, Radeon780M }, + GpuPreference.Discrete, Unset)); + } + + // ── Reading the cards ──────────────────────────────────────────────────── + + private static InMemoryFileSystem Card(InMemoryFileSystem files, string card, string driver, string id, + string slot, bool bootVga) => + files.WithFile($"/sys/class/drm/{card}/device/uevent", + $"DRIVER={driver}\nPCI_CLASS=30000\nPCI_ID={id}\nPCI_SLOT_NAME={slot}\n") + .WithFile($"/sys/class/drm/{card}/device/boot_vga", bootVga ? "1\n" : "0\n"); + + [Fact] + public void The_cards_are_read_from_sysfs() + { + var files = new InMemoryFileSystem(caseSensitive: true); + Card(files, "card0", "nvidia", "10DE:2D19", "0000:01:00.0", bootVga: false); + Card(files, "card1", "amdgpu", "1002:1900", "0000:65:00.0", bootVga: true); + // A connector, not a card. + files.WithFile("/sys/class/drm/card0-eDP-2/device/uevent", "DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=x\n"); + + files.WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n"); + + var gpus = new LinuxGpus(files, new FakeEnvironment()).List(); + + Assert.Equal(new[] { Nvidia, Radeon780M }, gpus); + } + + [Fact] + public void Off_linux_there_are_no_cards_to_move_between() + { + var files = new InMemoryFileSystem(caseSensitive: true); + Card(files, "card0", "nvidia", "10DE:2D19", "0000:01:00.0", bootVga: false); + + Assert.Empty(new LinuxGpus(files, new FakeEnvironment().AsWindows()).List()); + } + + [Fact] + public void Without_sysfs_there_are_no_cards() + { + Assert.Empty(new LinuxGpus(new InMemoryFileSystem(), new FakeEnvironment()).List()); + } +} diff --git a/StarterNG.Tests/GraphicsPresetTests.cs b/StarterNG.Tests/GraphicsPresetTests.cs new file mode 100644 index 0000000..6a7b4c4 --- /dev/null +++ b/StarterNG.Tests/GraphicsPresetTests.cs @@ -0,0 +1,628 @@ +using StarterNG.Application; +using StarterNG.Domain.Settings; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class GraphicsPresetTests +{ + private static SimulatorSettings Defaults() + { + var installation = new TestInstallation(); + installation.SettingsStore.Load(); + return installation.SettingsStore.Settings; + } + + private static SimulatorSettings AtHigh() + { + var settings = Defaults().Copy(); + GraphicsPreset.High.ApplyTo(settings); + return settings; + } + + [Fact] + public void The_keys_named_are_the_keys_a_level_sets() + { + var store = new TestInstallation().SettingsStore; + var low = Defaults().Copy(); + var top = Defaults().Copy(); + (GraphicsPreset.Minimal with { Extras = null }).ApplyTo(low); + (GraphicsPreset.Ultra with { Extras = null, Top = null }).ApplyTo(top); + + var lowKeys = store.Render(low); + var topKeys = store.Render(top); + var differing = lowKeys.Keys.Union(topKeys.Keys) + .Where(key => lowKeys.GetValueOrDefault(key) != topKeys.GetValueOrDefault(key)) + .Where(key => !key.StartsWith("gfx.framebuffer", StringComparison.Ordinal)); + + Assert.Equal(GraphicsPreset.Keys.OrderBy(k => k), differing.OrderBy(k => k)); + } + + // ── The scale ──────────────────────────────────────────────────────────── + + private static void Climbs(Func value, int repeatsAllowed) + { + var values = GraphicsPreset.All.Select(value).ToList(); + + Assert.Equal(values.OrderBy(v => v), values); + Assert.True(values.Count - values.Distinct().Count() <= repeatsAllowed, string.Join(", ", values)); + } + + [Fact] + public void Every_slider_moves_from_level_to_level_as_far_as_it_has_steps_for_six() + { + // Sizes and filtering run out of steps above High. + Climbs(p => p.TextureSize, 1); + Climbs(p => p.CabTextureSize, 1); + Climbs(p => p.Anisotropy, 1); + + // Four steps or three for six levels. + Climbs(p => p.Multisampling, 2); + Climbs(p => p.SmokeFidelity, 2); + Climbs(p => p.SplineFidelity, 2); + Climbs(p => p.ReflectionsFidelity, 3); + Climbs(p => p.ShadowRank, 3); + + // The simulator takes no more than its own 7 lights, and draws no less than 2 km. + Climbs(p => p.DynamicLights, 2); + Climbs(p => p.DrawRange, 1); + Climbs(p => p.DrawRangeFactor, 2); + Climbs(p => p.CabShadowsRange, 1); + + Climbs(p => p.ShadowMap, 1); + Climbs(p => p.ShadowRange, 0); + Climbs(p => p.ReflectionsRate, 0); + Climbs(p => p.DistanceFactor, 0); + Climbs(p => p.BufferScale, 0); + Climbs(p => p.SleeperDistance, 0); + Climbs(p => p.ReflectedInstances, 0); + Climbs(p => p.ReflectedVehicles, 0); + } + + [Fact] + public void No_step_up_a_scale_is_more_than_a_few_steps_of_its_slider() + { + // In steps of what the screen offers, so Ultra does not jump to the top from far below. + var rates = GraphicsPreset.All.Select(p => Steps.IndexOf(Steps.ReflectionUpdates, p.ReflectionsRate)).ToList(); + Assert.All(rates.Zip(rates.Skip(1)), step => Assert.InRange(step.Second - step.First, 1, 4)); + + foreach (Func fidelity in new Func[] { p => p.SmokeFidelity, p => p.SplineFidelity }) + { + var levels = GraphicsPreset.All.Select(fidelity).ToList(); + Assert.All(levels.Zip(levels.Skip(1)), step => Assert.InRange(step.Second - step.First, 0, 1)); + } + } + + [Fact] + public void High_is_the_simulators_own_but_where_that_is_the_bottom_of_a_scale() + { + var store = new TestInstallation().SettingsStore; + var own = store.Render(Defaults()); + var high = store.Render(AtHigh()); + + var differing = own.Keys.Union(high.Keys) + .Where(key => own.GetValueOrDefault(key) != high.GetValueOrDefault(key)) + .Where(key => !GraphicsPreset.RowKeys.Contains(key)) + .Where(key => !key.StartsWith("gfx.framebuffer", StringComparison.Ordinal)); + + Assert.Equal(new[] { "gfx.reflections.fidelity", "gfx.reflections.framerate", "gfx.shadows.cab.range", + "gfx.smoke.fidelity", "splinefidelity" }, differing.OrderBy(k => k)); + Assert.Equal(1, Defaults().SmokeFidelity); + Assert.Equal(1, Defaults().SplineFidelity); + } + + [Fact] + public void A_player_with_no_settings_of_their_own_starts_at_high() + { + var installation = new TestInstallation(); + var store = installation.SettingsStore; + store.Load(); + + store.StartAtHighWithoutOwnSettings(); + + Assert.Same(GraphicsPreset.High, GraphicsPreset.Matching(store.Settings)); + Assert.False(store.LastSaveFailed); + + var again = new TestInstallation(installation.Files).SettingsStore; + again.Load(); + Assert.Same(GraphicsPreset.High, GraphicsPreset.Matching(again.Settings)); + } + + [Fact] + public void A_player_with_settings_of_their_own_stays_as_they_are() + { + var installation = new TestInstallation(); + var store = installation.SettingsStore; + store.Load(); + GraphicsPreset.Low.ApplyTo(store.Settings); + store.Save(); + + var again = new TestInstallation(installation.Files).SettingsStore; + again.Load(); + again.StartAtHighWithoutOwnSettings(); + + Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(again.Settings)); + } + + [Fact] + public void Ultra_is_the_top_of_everything_the_screen_offers() + { + var ultra = GraphicsPreset.Ultra; + double Top(string key) => SimulatorKeys.Named(key).Maximum; + + Assert.Equal(8192, ultra.TextureSize); + Assert.Equal(8192, ultra.CabTextureSize); + Assert.Equal(8192, ultra.ShadowMap); + Assert.Equal(600, ultra.ShadowRange); + Assert.Equal(Steps.Anisotropy.Max(), ultra.Anisotropy); + Assert.Equal(Steps.MultisamplingLevels.Max(), ultra.Multisampling); + Assert.Equal(Steps.ReflectionUpdates.Max(), ultra.ReflectionsRate); + Assert.Equal(4, ultra.SmokeFidelity); + Assert.Equal(4, ultra.SplineFidelity); + Assert.Equal(200, ultra.BufferScale); + Assert.Equal(Top(GraphicsPreset.DrawRangeKey), ultra.DrawRange); + Assert.Equal(Top(GraphicsPreset.DistanceFactorKey), ultra.DistanceFactor); + Assert.Equal(Top(GraphicsPreset.SleeperKey), ultra.SleeperDistance); + Assert.Equal(Steps.DrawRangeFactors.Max(), ultra.DrawRangeFactor); + Assert.Equal(Top(GraphicsPreset.ReflectedInstancesKey), ultra.ReflectedInstances); + Assert.Equal(Top(GraphicsPreset.ReflectedVehiclesKey), ultra.ReflectedVehicles); + Assert.Equal(100, ultra.CabShadowsRange); + + var extras = ultra.Extras!; + Assert.Equal(200, extras.PythonUpdateMs); + Assert.True(extras.MotionBlur && extras.ChromaticAberration && extras.Smoke); + + var top = ultra.Top!; + Assert.Equal(Top(GraphicsTop.ScreenWindowFpsKey), top.ScreenWindowFps); + Assert.True(top.Screens && top.ScreenMipmaps && top.Sky && top.Cab && top.AirCouplers && top.SmoothTraction); + } + + [Fact] + public void At_ultra_what_would_only_take_away_is_off() + { + var top = GraphicsPreset.Ultra.Top!; + + // The longest shadows, the bottom of that scale. + Assert.Equal(0.2, top.ShadowAngleLimit); + Assert.Equal(0, top.TargetFps); + Assert.Equal(0, top.TextureRelease); + Assert.False(top.CompressTextures); + Assert.False(top.SkipDecor); + } + + [Fact] + public void Ultra_undoes_what_was_moved_earlier() + { + var settings = Defaults().Copy(); + settings.ShadowAngleLimit = 0.8; + settings.IgnoreIrrelevantTrains = true; + settings.Extra[GraphicsTop.TargetFpsKey] = "60"; + settings.Extra[GraphicsTop.TextureReleaseKey] = "2"; + + GraphicsPreset.Ultra.ApplyTo(settings); + + Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings)); + Assert.Equal(0.2, settings.ShadowAngleLimit); + Assert.False(settings.IgnoreIrrelevantTrains); + Assert.Equal("0", settings.Extra[GraphicsTop.TargetFpsKey]); + Assert.Equal("0", settings.Extra[GraphicsTop.TextureReleaseKey]); + } + + [Fact] + public void Ultra_leaves_the_screens_in_their_own_windows_to_the_player() + { + var settings = Defaults().Copy(); + settings.Extra["python.displaywindows"] = "yes"; + + GraphicsPreset.Ultra.ApplyTo(settings); + + Assert.Equal("yes", settings.Extra["python.displaywindows"]); + Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings)); + } + + [Fact] + public void The_other_levels_leave_what_only_ultra_sets() + { + foreach (var preset in GraphicsPreset.All.Where(preset => preset != GraphicsPreset.Ultra)) + { + var settings = Defaults().Copy(); + settings.IgnoreIrrelevantTrains = true; + settings.Extra[GraphicsTop.TargetFpsKey] = "60"; + + preset.ApplyTo(settings); + + Assert.True(settings.IgnoreIrrelevantTrains, preset.NameKey); + Assert.Equal("60", settings.Extra[GraphicsTop.TargetFpsKey]); + Assert.Same(preset, GraphicsPreset.Matching(settings)); + } + } + + [Fact] + public void Ultra_is_not_recognised_with_one_of_its_values_moved() + { + var settings = Defaults().Copy(); + GraphicsPreset.Ultra.ApplyTo(settings); + Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings)); + + settings.Extra[GraphicsPreset.ReflectedVehiclesKey] = "250"; + + Assert.Null(GraphicsPreset.Matching(settings)); + } + + [Fact] + public void No_level_asks_for_a_range_the_renderer_would_raise_anyway() + { + Assert.All(GraphicsPreset.All, preset => Assert.True(preset.DrawRange >= 2000, preset.NameKey)); + } + + [Fact] + public void The_view_only_shortens_going_down_the_levels() + { + var factors = GraphicsPreset.All.Select(preset => preset.DistanceFactor).ToList(); + + Assert.Equal(factors.OrderBy(factor => factor), factors); + Assert.Equal(1, GraphicsPreset.High.DistanceFactor); + } + + [Fact] + public void Shadows_reach_further_with_every_level_that_has_them() + { + static double Reach(GraphicsPreset preset) => + SimulatorSettings.ShadowReach(preset.ShadowRange, preset.ShadowMap, legacyRenderer: false); + + var reaches = GraphicsPreset.All.Where(preset => preset.Shadows).Select(Reach).ToList(); + + Assert.Equal(reaches.Distinct().OrderBy(reach => reach), reaches); + Assert.Equal(250, Reach(GraphicsPreset.High)); + Assert.Equal(800, Reach(GraphicsPreset.VeryHigh)); + } + + [Fact] + public void Without_extras_the_settings_are_what_the_simulator_goes_by() + { + Assert.Equal(GraphicsExtras.None, GraphicsExtras.Of(Defaults())); + Assert.Equal(GraphicsTop.None, GraphicsTop.Of(Defaults())); + } + + [Fact] + public void A_level_leaves_what_only_the_lowest_one_sets() + { + var settings = Defaults().Copy(); + settings.MotionBlur = false; + + GraphicsPreset.Low.ApplyTo(settings); + + Assert.False(settings.MotionBlur); + Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(settings)); + } + + [Fact] + public void Every_level_sets_the_buffer_and_is_not_recognised_with_another() + { + foreach (var preset in GraphicsPreset.All) + { + var settings = Defaults().Copy(); + settings.BufferScalePercent = 120; + + preset.ApplyTo(settings); + Assert.Equal(preset.BufferScale, settings.BufferScalePercent); + + settings.BufferScalePercent = 120; + Assert.Null(GraphicsPreset.Matching(settings)); + } + } + + [Fact] + public void The_buffer_is_the_screen_at_high_and_climbs_through_it() + { + Assert.Equal(100, GraphicsPreset.High.BufferScale); + Assert.Equal(200, GraphicsPreset.Ultra.BufferScale); + Assert.InRange(GraphicsPreset.Minimal.BufferScale, 50, 100); + } + + [Fact] + public void The_lowest_level_is_not_recognised_with_one_of_its_extras_undone() + { + var settings = Defaults().Copy(); + GraphicsPreset.Minimal.ApplyTo(settings); + + settings.Smoke = true; + + Assert.Null(GraphicsPreset.Matching(settings)); + } + + [Fact] + public void The_extras_sit_on_the_steps_the_screen_offers() + { + foreach (var extras in new[] { GraphicsPreset.Minimal.Extras!, GraphicsPreset.Ultra.Extras! }) + Assert.Contains(extras.PythonUpdateMs, new[] { 200, 400, 800, 1600 }); + } + + // ── Leaving an end of the scale ────────────────────────────────────────── + + [Fact] + public void A_level_between_the_ends_chooses_only_its_own_keys() + { + var settings = Defaults().Copy(); + settings.MotionBlur = false; + settings.Extra[GraphicsTop.TargetFpsKey] = "60"; + + var applied = new GraphicsPresetMemory().Apply(GraphicsPreset.Medium, settings); + + Assert.Same(GraphicsPreset.Medium, GraphicsPreset.Matching(settings)); + Assert.False(settings.MotionBlur); + Assert.Equal("60", settings.Extra[GraphicsTop.TargetFpsKey]); + Assert.Equal(GraphicsPreset.RowKeys, applied.RowKeys); + Assert.Equal(GraphicsPreset.Keys, applied.ChosenKeys); + } + + [Fact] + public void Leaving_the_lowest_level_brings_back_what_was_there_before_it() + { + var memory = new GraphicsPresetMemory(); + var settings = Defaults().Copy(); + settings.MotionBlur = false; + var before = GraphicsExtras.Of(settings); + + var entering = memory.Apply(GraphicsPreset.Minimal, settings); + Assert.Equal(GraphicsPreset.Minimal.Extras, GraphicsExtras.Of(settings)); + Assert.Equal(GraphicsPreset.RowKeys, entering.RowKeys); + Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys), entering.ChosenKeys); + + var leaving = memory.Apply(GraphicsPreset.Medium, settings); + + Assert.Equal(before, GraphicsExtras.Of(settings)); + Assert.Same(GraphicsPreset.Medium, GraphicsPreset.Matching(settings)); + Assert.Equal(GraphicsPreset.RowKeys, leaving.RowKeys); + Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys), leaving.ChosenKeys); + } + + [Fact] + public void Leaving_ultra_brings_back_what_was_there_before_it() + { + var memory = new GraphicsPresetMemory(); + var settings = Defaults().Copy(); + settings.MotionBlur = false; + settings.ShadowAngleLimit = 0.8; + settings.Extra[GraphicsTop.TargetFpsKey] = "60"; + var extras = GraphicsExtras.Of(settings); + var top = GraphicsTop.Of(settings); + + memory.Apply(GraphicsPreset.Ultra, settings); + var applied = memory.Apply(GraphicsPreset.VeryHigh, settings); + + Assert.Equal(extras, GraphicsExtras.Of(settings)); + Assert.Equal(top, GraphicsTop.Of(settings)); + Assert.Same(GraphicsPreset.VeryHigh, GraphicsPreset.Matching(settings)); + Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), applied.RowKeys); + Assert.Equal(GraphicsPreset.Keys.Concat(GraphicsExtras.Keys).Concat(GraphicsTop.Keys), applied.ChosenKeys); + } + + [Fact] + public void Leaving_an_end_chosen_before_this_run_gives_the_simulators_own_values() + { + var settings = Defaults().Copy(); + GraphicsPreset.Ultra.ApplyTo(settings); + + var applied = new GraphicsPresetMemory().Apply(GraphicsPreset.Low, settings); + + Assert.Equal(GraphicsExtras.None, GraphicsExtras.Of(settings)); + Assert.Equal(GraphicsTop.None, GraphicsTop.Of(settings)); + Assert.Same(GraphicsPreset.Low, GraphicsPreset.Matching(settings)); + Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), applied.RowKeys); + } + + [Fact] + public void One_end_to_the_other_keeps_what_was_there_before_either() + { + var memory = new GraphicsPresetMemory(); + var settings = Defaults().Copy(); + settings.MotionBlur = false; + var extras = GraphicsExtras.Of(settings); + var top = GraphicsTop.Of(settings); + + memory.Apply(GraphicsPreset.Minimal, settings); + var toUltra = memory.Apply(GraphicsPreset.Ultra, settings); + Assert.Same(GraphicsPreset.Ultra, GraphicsPreset.Matching(settings)); + Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), toUltra.RowKeys); + + var toMinimal = memory.Apply(GraphicsPreset.Minimal, settings); + Assert.Same(GraphicsPreset.Minimal, GraphicsPreset.Matching(settings)); + // Ultra's own values go, Minimal has no say over them. + Assert.Equal(top, GraphicsTop.Of(settings)); + Assert.Equal(GraphicsPreset.RowKeys.Concat(GraphicsTop.RowKeys), toMinimal.RowKeys); + + memory.Apply(GraphicsPreset.Medium, settings); + Assert.Equal(extras, GraphicsExtras.Of(settings)); + } + + [Fact] + public void What_was_remembered_comes_back_only_once() + { + var memory = new GraphicsPresetMemory(); + var settings = Defaults().Copy(); + settings.MotionBlur = false; + + memory.Apply(GraphicsPreset.Minimal, settings); + memory.Apply(GraphicsPreset.Medium, settings); + Assert.False(settings.MotionBlur); + + // Moved since, and no end left behind this time: it stays as moved. + settings.MotionBlur = true; + var applied = memory.Apply(GraphicsPreset.High, settings); + + Assert.True(settings.MotionBlur); + Assert.Equal(GraphicsPreset.RowKeys, applied.RowKeys); + Assert.Equal(GraphicsPreset.Keys, applied.ChosenKeys); + } + + [Fact] + public void A_level_applied_twice_at_an_end_remembers_what_came_before_the_first() + { + var memory = new GraphicsPresetMemory(); + var settings = Defaults().Copy(); + settings.MotionBlur = false; + var before = GraphicsExtras.Of(settings); + + memory.Apply(GraphicsPreset.Minimal, settings); + memory.Apply(GraphicsPreset.Minimal, settings); + memory.Apply(GraphicsPreset.Low, settings); + + Assert.Equal(before, GraphicsExtras.Of(settings)); + } + + [Fact] + public void A_level_applied_is_recognised_until_one_value_moves() + { + foreach (var preset in GraphicsPreset.All) + { + var settings = Defaults().Copy(); + preset.ApplyTo(settings); + Assert.Same(preset, GraphicsPreset.Matching(settings)); + + settings.DynamicLights = 6; + Assert.Null(GraphicsPreset.Matching(settings)); + } + } + + [Fact] + public void Every_level_sits_on_the_steps_the_screen_offers() + { + void OnSteps(double value, string name) + { + var key = SimulatorKeys.Named(name); + Assert.InRange(value, key.Minimum, key.Maximum); + double steps = value / key.Step; + Assert.Equal(Math.Round(steps), steps, 6); + } + + foreach (var preset in GraphicsPreset.All) + { + OnSteps(preset.DistanceFactor, GraphicsPreset.DistanceFactorKey); + OnSteps(preset.DrawRange, GraphicsPreset.DrawRangeKey); + OnSteps(preset.SleeperDistance, GraphicsPreset.SleeperKey); + OnSteps(preset.ReflectedInstances, GraphicsPreset.ReflectedInstancesKey); + OnSteps(preset.ReflectedVehicles, GraphicsPreset.ReflectedVehiclesKey); + Assert.True(Steps.IndexOf(Steps.Anisotropy, preset.Anisotropy) >= 0, preset.NameKey); + Assert.True(Steps.IndexOf(Steps.MultisamplingLevels, preset.Multisampling) >= 0, preset.NameKey); + Assert.True(Steps.IndexOf(Steps.ReflectionUpdates, preset.ReflectionsRate) >= 0, preset.NameKey); + Assert.True(Steps.IndexOf(Steps.DrawRangeFactors, preset.DrawRangeFactor) >= 0, preset.NameKey); + foreach (int size in new[] { preset.TextureSize, preset.CabTextureSize, preset.ShadowMap }) + Assert.True(size is >= 512 and <= 8192 && (size & (size - 1)) == 0, preset.NameKey); + Assert.True(preset.ShadowRange >= SimulatorSettings.ShadowRangeFloor(preset.ShadowMap), preset.NameKey); + Assert.Equal(0, preset.CabShadowsRange % 10); + } + } + + // ── A level for one part ───────────────────────────────────────────────── + + private static GraphicsGroupLevels Groups() => new(new TestInstallation().SettingsStore); + + [Fact] + public void Every_key_a_level_sets_but_the_buffer_belongs_to_one_part() + { + var inGroups = GraphicsGroup.All.SelectMany(group => group.Keys).ToList(); + + Assert.Equal(inGroups.Distinct(), inGroups); + Assert.Empty(inGroups.Except(GraphicsPreset.Keys).Except(GraphicsExtras.Keys).Except(GraphicsTop.Keys)); + Assert.Equal(new[] { "starter.bufferscale" }, GraphicsPreset.Keys.Except(inGroups)); + } + + [Fact] + public void One_part_moves_alone() + { + var groups = Groups(); + var settings = AtHigh(); + + var moved = groups.Apply(GraphicsGroup.Shadows, GraphicsPreset.Low, settings); + + Assert.Equal(GraphicsPreset.Low.ShadowMap, moved.ShadowMapResolution); + Assert.Equal(GraphicsPreset.Low.ShadowRange, moved.ShadowProjectionRange); + Assert.Equal(GraphicsPreset.Low.ShadowRank, moved.ShadowRankCutoff); + Assert.Equal(settings.MaxTextureSize, moved.MaxTextureSize); + Assert.Equal(settings.DynamicLights, moved.DynamicLights); + Assert.Equal(settings.BufferScalePercent, moved.BufferScalePercent); + + var levels = groups.LevelsOf(moved); + Assert.Same(GraphicsPreset.Low, levels[GraphicsGroup.Shadows]); + Assert.Same(GraphicsPreset.High, levels[GraphicsGroup.Textures]); + Assert.Null(GraphicsPreset.Matching(moved)); + } + + [Fact] + public void Every_part_shows_ultra_when_the_whole_is_at_ultra() + { + var settings = Defaults().Copy(); + GraphicsPreset.Ultra.ApplyTo(settings); + + Assert.All(Groups().LevelsOf(settings).Values, level => Assert.Same(GraphicsPreset.Ultra, level)); + + // The row of a key at the simulator's own value leaves it out of the file. + settings.Extra.Remove(GraphicsTop.TextureReleaseKey); + + Assert.Same(GraphicsPreset.Ultra, Groups().LevelsOf(settings)[GraphicsGroup.Textures]); + } + + [Fact] + public void A_part_moved_by_hand_is_at_no_level_and_the_others_keep_theirs() + { + var settings = AtHigh(); + settings.DynamicLights = 6; + + var levels = Groups().LevelsOf(settings); + + Assert.Null(levels[GraphicsGroup.Effects]); + Assert.Same(GraphicsPreset.High, levels[GraphicsGroup.Shadows]); + } + + [Fact] + public void Every_part_at_a_level_and_the_buffer_with_it_is_that_level() + { + var groups = Groups(); + foreach (var level in GraphicsPreset.All.Where(level => level.Extras is null)) + { + var settings = Defaults().Copy(); + foreach (var group in GraphicsGroup.All) + settings = groups.Apply(group, level, settings); + settings.BufferScalePercent = level.BufferScale; + + Assert.Same(level, GraphicsPreset.Matching(settings)); + } + } + + [Fact] + public void Every_part_tells_every_level_from_the_others() + { + var groups = Groups(); + foreach (var group in GraphicsGroup.All) + foreach (var level in GraphicsPreset.All) + Assert.Same(level, groups.LevelsOf(groups.Apply(group, level, Defaults()))[group]); + } + + [Fact] + public void Leaving_an_end_in_a_part_gives_the_simulators_own_for_what_only_the_end_sets() + { + var groups = Groups(); + var settings = AtHigh(); + + var ultra = groups.Apply(GraphicsGroup.Textures, GraphicsPreset.Ultra, settings); + Assert.False(ultra.CompressTextures); + Assert.Same(GraphicsPreset.Ultra, groups.LevelsOf(ultra)[GraphicsGroup.Textures]); + + var back = groups.Apply(GraphicsGroup.Textures, GraphicsPreset.High, ultra); + Assert.Equal(GraphicsTop.None.CompressTextures, back.CompressTextures); + Assert.Same(GraphicsPreset.High, groups.LevelsOf(back)[GraphicsGroup.Textures]); + } + + [Fact] + public void A_level_in_a_part_between_the_ends_leaves_what_only_an_end_sets() + { + var settings = AtHigh(); + settings.MotionBlur = false; + + var moved = Groups().Apply(GraphicsGroup.Effects, GraphicsPreset.VeryHigh, settings); + + Assert.False(moved.MotionBlur); + Assert.Equal(GraphicsPreset.VeryHigh.SmokeFidelity, moved.SmokeFidelity); + } +} diff --git a/StarterNG.Tests/KeyMapTests.cs b/StarterNG.Tests/KeyMapTests.cs new file mode 100644 index 0000000..afd1351 --- /dev/null +++ b/StarterNG.Tests/KeyMapTests.cs @@ -0,0 +1,119 @@ +using StarterNG.Classes; + +namespace StarterNG.Tests; + +public class KeyMapTests +{ + /// The names of the simulator's key table, keytonamemap in input/keyboardinput.cpp. + private static readonly HashSet SimulatorKeyNames = new(StringComparer.Ordinal) + { + "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "-", "=", + "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", + "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", + "backspace", "[", "]", "\\", ";", "'", "enter", ",", ".", "/", "space", "pause", + "insert", "delete", "home", "end", + "num_/", "num_*", "num_-", "num_+", "num_enter", "num_.", + "num_0", "num_1", "num_2", "num_3", "num_4", "num_5", "num_6", "num_7", "num_8", "num_9", + "f1", "f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "f10", "f11", "f12", + "tab", "esc", "left", "right", "up", "down", "page_up", "page_down" + }; + + [Theory] + [InlineData("tab", "KbUseMap")] + [InlineData("f1", "KbUseConsist")] + [InlineData("f2", "KbUseTimetable")] + [InlineData("f3", "KbUseScenario")] + [InlineData("f9", "KbUseLog")] + [InlineData("f10", "KbUseQuit")] + public void What_the_simulator_takes_first_blocks_only_without_a_modifier(string token, string text) + { + Assert.Equal(new SimulatorKeyUse(text, true), KeyMap.SimulatorUse(token, false, false)); + Assert.Null(KeyMap.SimulatorUse(token, true, false)); + Assert.Null(KeyMap.SimulatorUse(token, false, true)); + } + + [Fact] + public void Shift_F12_blocks_and_F12_alone_is_free() + { + Assert.Equal(new SimulatorKeyUse("KbUseDebugPanel", true), KeyMap.SimulatorUse("f12", true, false)); + Assert.Null(KeyMap.SimulatorUse("f12", false, false)); + } + + [Theory] + [InlineData("f4", false, false, "KbUseCab")] + [InlineData("f4", true, false, "KbUseCab")] + [InlineData("f5", false, true, "KbUseVehicle")] + [InlineData("f6", false, false, "KbUseDebug")] + [InlineData("f7", false, true, "KbUseDebug")] + [InlineData("f11", false, false, "KbUseEditor")] + [InlineData("3", true, false, "KbUseSceneryEvent")] + [InlineData("3", false, true, "KbUseCamera")] + public void What_is_left_to_a_key_a_binding_may_replace(string token, bool shift, bool ctrl, string text) + { + Assert.Equal(new SimulatorKeyUse(text, false), KeyMap.SimulatorUse(token, shift, ctrl)); + } + + [Theory] + [InlineData("3", false, false)] + [InlineData("a", false, false)] + [InlineData("a", true, true)] + [InlineData("f7", true, false)] + [InlineData("f8", false, false)] + [InlineData("f11", true, false)] + [InlineData("num_5", true, false)] + [InlineData("enter", false, false)] + public void The_rest_the_simulator_leaves_alone(string token, bool shift, bool ctrl) + { + Assert.Null(KeyMap.SimulatorUse(token, shift, ctrl)); + } + + [Fact] + public void The_bundled_keys_use_nothing_the_simulator_takes_first() + { + var config = new KeyboardConfig(); + string path = Path.Combine(AppContext.BaseDirectory, "startercfg", "eu07_input-keyboard.ini"); + config.Parse(File.ReadAllText(path)); + + var taken = config.Bindings + .Where(b => b.IsAssigned && KeyMap.SimulatorUse(b.Key, b.Shift, b.Ctrl) is { Blocks: true }) + .Select(b => b.Command) + .ToList(); + + Assert.Empty(taken); + Assert.Contains(config.Bindings, b => b.IsAssigned); + } + + [Fact] + public void Every_key_drawn_is_one_the_simulator_knows() + { + var drawn = KeyMap.MainBlock.Concat(KeyMap.NumpadBlock).Concat(KeyMap.NavBlock) + .SelectMany(row => row) + .Select(cap => cap.Token) + .OfType(); + + Assert.Empty(drawn.Where(token => !SimulatorKeyNames.Contains(token))); + } + + [Theory] + [InlineData("page_up", "Page Up")] + [InlineData("page_down", "Page Down")] + [InlineData("esc", "Esc")] + [InlineData("num_enter", "Num Enter")] + public void Key_names_read_as_on_a_keyboard(string token, string expected) + { + Assert.Equal(expected, KeyMap.DisplayName(token)); + } + + [Theory] + [InlineData("a", false, false, "A")] + [InlineData("a", true, false, "Shift + A")] + [InlineData("a", false, true, "Ctrl + A")] + [InlineData("num_enter", true, true, "Ctrl + Shift + Num Enter")] + [InlineData("none", true, true, "—")] + [InlineData("NONE", false, true, "—")] + [InlineData("", true, false, "—")] + public void A_combination_reads_with_its_modifiers_unless_there_is_no_key(string token, bool shift, bool ctrl, string expected) + { + Assert.Equal(expected, KeyMap.ComboText(token, shift, ctrl)); + } +} diff --git a/StarterNG.Tests/KeyboardConfigTests.cs b/StarterNG.Tests/KeyboardConfigTests.cs index 7f20bac..b5c12cd 100644 --- a/StarterNG.Tests/KeyboardConfigTests.cs +++ b/StarterNG.Tests/KeyboardConfigTests.cs @@ -7,4 +7,151 @@ public class KeyboardConfigTests [Fact] public void A_description_without_polish_letters_matches_the_bundled_one() => Assert.Equal("zwiekszenie nastawnika glownego", KeyboardConfig.StripPolish("zwiększenie nastawnika głównego")); + + private static KeyBinding Parse(string line) + { + var config = new KeyboardConfig(); + config.Parse(line); + return Assert.Single(config.Bindings); + } + + [Fact] + public void A_binding_without_a_description_shows_the_translated_one() + { + var binding = Parse("hornhighactivate a"); + + string text = new KeyboardConfig().DescriptionOf(binding, _ => "high tone horn"); + + Assert.Equal("high tone horn", text); + } + + [Fact] + public void A_description_someone_wrote_wins_over_the_translation() + { + var binding = Parse("hornhighactivate a // my own horn"); + + string text = new KeyboardConfig().DescriptionOf(binding, _ => "high tone horn"); + + Assert.Equal("my own horn", text); + } + + [Fact] + public void Without_a_translation_the_file_description_stays() + { + var binding = Parse("hornhighactivate a // syrena wysoka"); + + Assert.Equal("syrena wysoka", new KeyboardConfig().DescriptionOf(binding, _ => string.Empty)); + } + + // ── Which binding sits where ───────────────────────────────────────────── + + private static KeyboardConfig Config(params string[] lines) + { + var config = new KeyboardConfig(); + config.Parse(string.Join("\n", lines)); + return config; + } + + [Fact] + public void A_cleared_binding_is_on_no_key_and_no_modifier() + { + var binding = Parse("hornhighactivate shift ctrl a"); + + binding.Clear(); + + Assert.False(binding.IsAssigned); + Assert.False(binding.Shift); + Assert.False(binding.Ctrl); + Assert.Equal("none", binding.Key); + } + + [Theory] + [InlineData("a", true, false, true)] + [InlineData("A", true, false, true)] + [InlineData("a", false, false, false)] + [InlineData("a", true, true, false)] + [InlineData("b", true, false, false)] + public void A_binding_is_on_its_key_with_exactly_its_modifiers(string token, bool shift, bool ctrl, bool on) => + Assert.Equal(on, Parse("hornhighactivate shift a").IsOn(token, shift, ctrl)); + + [Fact] + public void An_unassigned_binding_is_on_nothing_not_even_the_word_none() => + Assert.False(Parse("hornhighactivate none").IsOn("none", false, false)); + + [Fact] + public void The_binding_at_a_combination_is_found_by_key_and_modifiers() + { + var config = Config("plain a", "shifted shift a"); + + Assert.Equal("plain", config.BindingAt("A", false, false)?.Command); + Assert.Equal("shifted", config.BindingAt("a", true, false)?.Command); + Assert.Null(config.BindingAt("a", false, true)); + } + + [Fact] + public void A_combination_put_on_a_binding_moves_there_from_the_one_that_had_it() + { + var config = Config("first ctrl a", "second b", "third a"); + + config.Put(config.Bindings[1], "a", shift: false, ctrl: true); + + Assert.False(config.Bindings[0].IsAssigned); + Assert.False(config.Bindings[0].Ctrl); + Assert.True(config.Bindings[1].IsOn("a", false, true)); + // The same key with other modifiers is another combination. + Assert.True(config.Bindings[2].IsOn("a", false, false)); + } + + [Fact] + public void Every_binding_doubled_up_on_a_combination_leaves_it_but_the_one_put_there() + { + var config = Config("first a", "second a", "third a"); + + config.Put(config.Bindings[1], "a", shift: false, ctrl: false); + + Assert.False(config.Bindings[0].IsAssigned); + Assert.True(config.Bindings[1].IsOn("a", false, false)); + Assert.False(config.Bindings[2].IsAssigned); + } + + [Fact] + public void Nothing_put_on_a_combination_frees_it() + { + var config = Config("first shift a", "second b"); + + config.Put(null, "a", shift: true, ctrl: false); + + Assert.False(config.Bindings[0].IsAssigned); + Assert.True(config.Bindings[1].IsOn("b", false, false)); + } + + [Fact] + public void A_combination_put_back_on_the_binding_that_has_it_stays() + { + var config = Config("first a"); + + config.Put(config.Bindings[0], "a", shift: false, ctrl: false); + + Assert.True(config.Bindings[0].IsOn("a", false, false)); + } + + [Fact] + public void Two_bindings_on_a_combination_conflict_however_the_key_is_spelled() + { + var config = Config("first shift a", "second shift A", "third a", "fourth ctrl a", "fifth none", "sixth none"); + + var conflicts = config.ConflictingCombos(); + + Assert.Equal(config.Bindings[0].ComboKey, Assert.Single(conflicts)); + Assert.Contains(config.Bindings[1].ComboKey, conflicts); + Assert.DoesNotContain(config.Bindings[2].ComboKey, conflicts); + } + + [Fact] + public void Only_bindings_on_a_key_of_the_numpad_count_as_on_it() + { + var config = Config("first num_1", "second shift NUM_enter", "third a", "fourth none"); + + Assert.Equal(2, config.BindingsOnNumpad()); + } } diff --git a/StarterNG.Tests/ResolutionChoicesTests.cs b/StarterNG.Tests/ResolutionChoicesTests.cs new file mode 100644 index 0000000..668e132 --- /dev/null +++ b/StarterNG.Tests/ResolutionChoicesTests.cs @@ -0,0 +1,103 @@ +using StarterNG.Domain.Settings; + +namespace StarterNG.Tests; + +public class ResolutionChoicesTests +{ + private static List Listed(ScreenSize[] monitors, ScreenSize current) => + ResolutionChoices.For(monitors, current).Select(c => c.Size.ToString()).ToList(); + + [Fact] + public void The_monitors_come_first_and_are_marked() + { + var choices = ResolutionChoices.For(new[] { new ScreenSize(1920, 1200), new ScreenSize(2560, 1440) }, + new ScreenSize(1280, 720)); + + Assert.Equal(new ScreenSize(1920, 1200), choices[0].Size); + Assert.Equal(new ScreenSize(2560, 1440), choices[1].Size); + Assert.True(choices[0].IsMonitor); + Assert.True(choices[1].IsMonitor); + Assert.All(choices.Skip(2), c => Assert.False(c.IsMonitor)); + } + + [Fact] + public void A_size_no_monitor_can_show_is_not_offered() + { + var listed = Listed(new[] { new ScreenSize(1920, 1080) }, new ScreenSize(1280, 720)); + + Assert.Contains("1920x1080", listed); + Assert.Contains("1600x900", listed); + Assert.DoesNotContain("3840x2160", listed); + Assert.DoesNotContain("2560x1440", listed); + // Taller than the screen, though no wider. + Assert.DoesNotContain("1920x1200", listed); + } + + [Fact] + public void A_size_fitting_any_of_the_monitors_is_offered() + { + var listed = Listed(new[] { new ScreenSize(1920, 1080), new ScreenSize(3440, 1440) }, new ScreenSize(1280, 720)); + + Assert.Contains("2560x1080", listed); + Assert.DoesNotContain("3840x2160", listed); + } + + [Theory] + [InlineData(1920, 1200)] + [InlineData(1280, 800)] + [InlineData(2560, 1080)] + [InlineData(3440, 1440)] + public void The_monitors_size_is_there_even_where_it_is_not_a_usual_one(int width, int height) + { + var listed = Listed(new[] { new ScreenSize(width, height) }, new ScreenSize(1024, 768)); + + Assert.Equal($"{width}x{height}", listed[0]); + } + + [Fact] + public void The_size_asked_for_stays_on_the_list_whatever_it_is() + { + var listed = Listed(new[] { new ScreenSize(1920, 1080) }, new ScreenSize(5120, 1440)); + + Assert.Contains("5120x1440", listed); + Assert.Single(listed, "5120x1440"); + } + + [Fact] + public void The_rest_goes_from_the_largest_down_without_repeats() + { + var listed = Listed(new[] { new ScreenSize(2560, 1440) }, new ScreenSize(1920, 1080)); + + Assert.Equal(listed.Count, listed.Distinct().Count()); + var sizes = ResolutionChoices.For(new[] { new ScreenSize(2560, 1440) }, new ScreenSize(1920, 1080)) + .Skip(1).Select(c => c.Size).ToList(); + Assert.Equal(sizes.OrderByDescending(s => s.Width).ThenByDescending(s => s.Height), sizes); + } + + [Fact] + public void Two_monitors_of_a_size_are_one_entry() + { + var choices = ResolutionChoices.For(new[] { new ScreenSize(1920, 1080), new ScreenSize(1920, 1080) }, + new ScreenSize(1920, 1080)); + + Assert.Single(choices, c => c.Size == new ScreenSize(1920, 1080)); + } + + [Fact] + public void With_no_monitors_known_everything_usual_is_offered() + { + var listed = Listed(Array.Empty(), new ScreenSize(1280, 720)); + + Assert.Contains("3840x2160", listed); + Assert.Contains("1280x800", listed); + } + + [Fact] + public void A_size_of_nothing_is_not_listed() + { + var listed = Listed(new[] { new ScreenSize(0, 0) }, new ScreenSize(0, 0)); + + Assert.DoesNotContain("0x0", listed); + Assert.NotEmpty(listed); + } +} diff --git a/StarterNG.Tests/SettingReachTests.cs b/StarterNG.Tests/SettingReachTests.cs new file mode 100644 index 0000000..77e6e4f --- /dev/null +++ b/StarterNG.Tests/SettingReachTests.cs @@ -0,0 +1,128 @@ +using StarterNG.Domain.Settings; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class SettingReachTests +{ + private static SimulatorTarget Linux(string renderer = "full", bool server = false) => + new(WindowsBuild: false, renderer, server); + + [Theory] + [InlineData("feedbackmode")] + [InlineData("feedbackport")] + [InlineData("showsystemconsole")] + [InlineData("gfx.angleplatform")] + public void Windows_only_keys_idle_on_a_linux_build(string key) + { + Assert.Equal(IdleReason.WindowsBuildOnly, SettingReach.Why(key, Linux())); + Assert.Equal(IdleReason.None, SettingReach.Why(key, Linux() with { WindowsBuild = true })); + } + + [Theory] + [InlineData("full", IdleReason.LegacyRendererOnly)] + [InlineData("experimental", IdleReason.LegacyRendererOnly)] + [InlineData("legacy", IdleReason.None)] + [InlineData("simpleshader", IdleReason.None)] + [InlineData("simple", IdleReason.None)] + public void Legacy_renderer_keys_follow_the_renderer(string renderer, IdleReason expected) + { + Assert.Equal(expected, SettingReach.Why("smoothtraction", Linux(renderer))); + Assert.Equal(expected, SettingReach.Why("usevbo", Linux(renderer))); + Assert.Equal(expected, SettingReach.Why("gfx.shadows.cab.range", Linux(renderer))); + } + + [Fact] + public void Pausing_in_the_background_needs_a_network_server() + { + Assert.Equal(IdleReason.NeedsNetworkServer, SettingReach.Why("inactivepause", Linux())); + Assert.Equal(IdleReason.None, SettingReach.Why("inactivepause", Linux(server: true))); + } + + [Fact] + public void The_starters_own_keys_never_idle() + { + Assert.Equal(IdleReason.None, SettingReach.Why("starter.autoclose", Linux())); + } + + [Theory] + [InlineData("width, height")] + [InlineData("fullscreen")] + public void The_monitors_own_mode_overrides_size_and_full_screen(string key) + { + Assert.Equal(IdleReason.MonitorModeInstead, + SettingReach.Why(key, Linux() with { FullscreenWindowed = true })); + Assert.Equal(IdleReason.None, SettingReach.Why(key, Linux())); + } + + [Fact] + public void An_ordinary_key_works_everywhere() + { + Assert.Equal(IdleReason.None, SettingReach.Why("fieldofview", Linux())); + } + + [Theory] + [InlineData("full", IdleReason.None)] + [InlineData("legacy", IdleReason.ModernRendererOnly)] + [InlineData("experimental", IdleReason.ModernRendererOnly)] + public void Effects_of_the_full_renderer_follow_the_renderer(string renderer, IdleReason expected) + { + Assert.Equal(expected, SettingReach.Why("gfx.postfx.motionblur.enabled", Linux(renderer))); + Assert.Equal(expected, SettingReach.Why("gfx.envmap.enabled", Linux(renderer))); + Assert.Equal(expected, SettingReach.Why("sleeperdistance", Linux(renderer))); + } + + [Theory] + [InlineData("full", IdleReason.None)] + [InlineData("legacy", IdleReason.None)] + [InlineData("experimental", IdleReason.OpenGlRenderersOnly)] + public void The_experimental_renderer_passes_over_what_only_opengl_reads(string renderer, IdleReason expected) + { + Assert.Equal(expected, SettingReach.Why("multisampling", Linux(renderer))); + Assert.Equal(expected, SettingReach.Why("anisotropicfiltering", Linux(renderer))); + Assert.Equal(expected, SettingReach.Why("gfx.smoke", Linux(renderer))); + // ...but reads the rest of the reflections and shadows. + Assert.Equal(IdleReason.None, SettingReach.Why("gfx.reflections.fidelity", Linux(renderer))); + Assert.Equal(IdleReason.None, SettingReach.Why("shadowtune", Linux(renderer))); + } + + [Theory] + [InlineData(null, 0, false)] + [InlineData(0.0, 0, false)] + [InlineData(60.0, 0, true)] + [InlineData(null, 50, true)] // fpsaverage, the older regulator + public void The_draw_range_factor_needs_a_regulator(double? targetFps, double fpsAverage, bool regulated) + { + var target = Linux() with { TargetFps = targetFps, FpsAverage = fpsAverage }; + + Assert.Equal(regulated ? IdleReason.None : IdleReason.NeedsFrameRateTarget, + SettingReach.Why("gfx.drawrange.factor.max", target)); + } + + // ── The rest of the target ─────────────────────────────────────────────── + + [Fact] + public void A_windows_build_is_told_from_its_header() + { + var files = new InMemoryFileSystem() + .WithExecutable(TestInstallation.At("eu07"), "\u007fELF binary") + .WithFile(TestInstallation.At("eu07.exe"), "MZ binary"); + var installation = new TestInstallation(files); + + Assert.False(installation.Executables.IsWindowsBuild("eu07")); + Assert.True(installation.Executables.IsWindowsBuild("eu07.exe")); + } + + [Fact] + public void A_network_server_in_the_ini_is_noticed() + { + string ini = Path.Combine("/home/kolejarz", ".config", "MaSzyna", "eu07.ini"); + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(ini, "network.server tcp 0.0.0.0:7424\n")); + installation.Environment.With("HOME", "/home/kolejarz"); + + installation.SettingsStore.Load(); + + Assert.True(installation.SettingsStore.Settings.NetworkServer); + } +} diff --git a/StarterNG.Tests/SettingsStoreTests.cs b/StarterNG.Tests/SettingsStoreTests.cs index f5501e5..6d8c21f 100644 --- a/StarterNG.Tests/SettingsStoreTests.cs +++ b/StarterNG.Tests/SettingsStoreTests.cs @@ -15,6 +15,30 @@ public class SettingsStoreTests return installation; } + /// + /// The saved file with each of its lines ended by \n, whichever ending the host + /// writes, so a check for "key value\n" holds the line to exactly that. + /// + private static string SavedLines(TestInstallation installation) => + installation.Files.ReadAllText(UserIni).Replace("\r\n", "\n") + "\n"; + + /// What the file says for a key, its value included; null when no line sets it. + private static string? LineFor(TestInstallation installation, string key) => + SavedLines(installation).Split('\n') + .SingleOrDefault(line => line == key || line.StartsWith(key + " ")); + + /// + /// Simulator keys left with no value. The simulator would take the next word of + /// the file for it; a starter.* key is exempt, as it skips those. + /// + private static string[] BareSimulatorKeys(TestInstallation installation) => + SavedLines(installation).Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0 && !line.StartsWith("//") && !line.Any(char.IsWhiteSpace)) + .Where(key => !key.StartsWith("starter.", StringComparison.OrdinalIgnoreCase) && + !key.Equals("endconfig", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + [Fact] public void Settings_are_read_from_the_users_ini() { @@ -60,6 +84,54 @@ public class SettingsStoreTests Assert.Contains("lang zh", installation.Files.ReadAllText(UserIni)); } + [Fact] + public void Uart_features_with_nothing_selected_are_written_out_in_full() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + var settings = installation.SettingsStore.Settings; + settings.UartMain = settings.UartScnd = settings.UartTrain = settings.UartLocal = false; + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Split('\n').Select(l => l.Trim()); + Assert.Contains("uartfeature no no no no", lines); + Assert.DoesNotContain("uartfeature", lines); + } + + [Theory] + [InlineData("uartfeature\nuartdebug no\n", false, false)] + [InlineData("uartfeature none\n", false, false)] + [InlineData("uartfeature no no no no\n", false, false)] + [InlineData("uartfeature yes yes yes yes\n", true, true)] + [InlineData("uartfeature YES no no no\n", true, false)] + [InlineData("uartfeature main|scnd|\n", true, true)] + public void Uart_features_are_read_in_every_form_the_simulator_takes(string file, bool main, bool scnd) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, file)); + + installation.SettingsStore.Load(); + + Assert.Equal(main, installation.SettingsStore.Settings.UartMain); + Assert.Equal(scnd, installation.SettingsStore.Settings.UartScnd); + } + + [Theory] + [InlineData("")] + [InlineData("uartdebug no")] + [InlineData("1 2 3")] + public void A_uart_tune_that_is_not_eighteen_numbers_is_not_written(string tune) + { + var installation = WithHome(); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartTune = tune; + + installation.SettingsStore.Save(); + + Assert.DoesNotContain(installation.Files.ReadAllText(UserIni).Split('\n'), + line => line.TrimStart().StartsWith("uarttune", StringComparison.Ordinal)); + } + [Fact] public void With_no_language_recorded_the_file_still_names_one() { @@ -150,6 +222,493 @@ public class SettingsStoreTests Assert.Equal(60, reopened.SettingsStore.Settings.FieldOfView); } + [Theory] + [InlineData("uarttune uartdebug no\n", "")] + [InlineData("uarttune 1 2 3\n", "1 2 3")] + public void A_calibration_is_shown_only_if_the_file_holds_numbers_for_it(string line, string shown) + { + // The next key of the file on the same line is no calibration, but a wrong count of numbers is one to fix. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line)); + + installation.SettingsStore.Load(); + + Assert.Equal(shown, installation.SettingsStore.Settings.UartTune); + } + + [Theory] + [InlineData("uarttune COM3\n", null)] + [InlineData("", null)] + [InlineData("uarttune 0 65535 0 65535 10 65535 10 65535 10 65535 10 65535 100000 65535 10000 65535 150 65535\n", + "uarttune 0 65535 0 65535 10 65535 10 65535 10 65535 10 65535 100000 65535 10000 65535 150 65535")] + public void The_uart_calibration_is_written_only_as_its_eighteen_numbers(string line, string? written) + { + // Anything else and the simulator reads the next keys of the file for them: + // maxcabtexturesize 2048 went so into one. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, line + "maxcabtexturesize 2048\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Split('\n').Select(l => l.TrimEnd('\r')); + Assert.Equal(written, lines.SingleOrDefault(l => l.StartsWith("uarttune"))); + } + + [Fact] + public void The_uart_line_names_port_speed_and_interval() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "uart /dev/ttyACM0 115200 0.05\n")); + installation.SettingsStore.Load(); + var s = installation.SettingsStore.Settings; + + Assert.True(s.UartEnabled); + Assert.Equal("/dev/ttyACM0", s.UartPort); + Assert.Equal(115200, s.UartBaud); + Assert.Equal(0.05, s.UartUpdateTime); + + s.UartPort = "COM3"; + installation.SettingsStore.Save(); + + Assert.Contains("uart COM3 115200 0.05", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_bare_uart_port_is_completed_rather_than_left_to_swallow_the_next_keys() + { + // The simulator reads three words; "uart COM3" alone took the next line's key + // and value for the speed and interval. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "uart COM3\nmaxcabtexturesize 2048\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + var text = installation.Files.ReadAllText(UserIni); + Assert.Contains($"uart COM3 {Uart.DefaultBaud} 0.1", text); + Assert.Contains("maxcabtexturesize 2048", text); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("COM 3")] + public void Without_a_one_word_port_no_uart_line_is_written(string port) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "uart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartPort = port; + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Split('\n'); + Assert.DoesNotContain(lines, l => l.StartsWith("uart ")); + } + + [Fact] + public void A_disabled_uart_leaves_no_uart_line() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "uart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartEnabled = false; + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Split('\n'); + Assert.DoesNotContain(lines, l => l.StartsWith("uart ")); + } + + [Fact] + public void The_graphics_card_choice_survives_a_round_trip() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + Assert.Equal(GpuPreference.Discrete, installation.SettingsStore.Settings.Gpu); + + installation.SettingsStore.Settings.Gpu = GpuPreference.System; + installation.SettingsStore.Save(); + + Assert.Contains("starter.gpu system", installation.Files.ReadAllText(UserIni)); + var reopened = WithHome(installation.Files); + reopened.SettingsStore.Load(); + Assert.Equal(GpuPreference.System, reopened.SettingsStore.Settings.Gpu); + } + + [Fact] + public void A_silenced_processor_notice_stays_silent() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + Assert.True(installation.SettingsStore.Settings.CpuNotice); + + installation.SettingsStore.Settings.CpuNotice = false; + installation.SettingsStore.Save(); + + var reopened = WithHome(installation.Files); + reopened.SettingsStore.Load(); + Assert.False(reopened.SettingsStore.Settings.CpuNotice); + } + + [Fact] + public void Uart_features_all_off_are_written_as_four_noes() + { + // "none" only newer simulators read; every build reads the four words. + var installation = WithHome(); + installation.SettingsStore.Load(); + var s = installation.SettingsStore.Settings; + s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = s.UartRadioVolume = s.UartRadioChannel = false; + + installation.SettingsStore.Save(); + + Assert.Equal("uartfeature no no no no", LineFor(installation, "uartfeature")); + } + + [Fact] + public void Dynamic_brake_alone_is_written_in_the_pipe_form() + { + // The four words cannot say it, and would switch it off. + var installation = WithHome(); + installation.SettingsStore.Load(); + var s = installation.SettingsStore.Settings; + s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = false; + s.UartDynamicBrake = true; + + installation.SettingsStore.Save(); + + Assert.Equal("uartfeature dynamicbrake|", LineFor(installation, "uartfeature")); + } + + [Theory] + // Globals.cpp: a first word with a pipe, "none" or a feature's name is the list. + [InlineData("main|scnd|train|local|radiovolume|radiochannel|dynamicbrake", "main scnd train local radiovolume radiochannel dynamicbrake")] + [InlineData("main|dynamicbrake|", "main dynamicbrake")] + [InlineData("MAIN|Train", "main train")] + [InlineData("main", "main")] + [InlineData("dynamicbrake", "dynamicbrake")] + [InlineData("nonsense|local", "local")] + [InlineData("none", "")] + [InlineData("NONE", "")] + // Anything else four words, for the first four features: parser.cpp takes true, yes and 1. + [InlineData("yes yes yes yes", "main scnd train local")] + [InlineData("no no no no", "")] + [InlineData("YES no Yes NO", "main train")] + [InlineData("1 0 1 0", "main train")] + [InlineData("true false false true", "main local")] + // Bare: nothing selected, whatever the next line says. + [InlineData("", "")] + public void A_uartfeature_line_is_read_as_the_simulator_reads_it(string value, string features) + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, $"uartfeature {value}\nwidth 800\n")); + + installation.SettingsStore.Load(); + + var s = installation.SettingsStore.Settings; + var on = new List(); + if (s.UartMain) on.Add("main"); + if (s.UartScnd) on.Add("scnd"); + if (s.UartTrain) on.Add("train"); + if (s.UartLocal) on.Add("local"); + if (s.UartRadioVolume) on.Add("radiovolume"); + if (s.UartRadioChannel) on.Add("radiochannel"); + if (s.UartDynamicBrake) on.Add("dynamicbrake"); + Assert.Equal(features, string.Join(' ', on)); + Assert.Equal(800, s.Width); + } + + [Theory] + [InlineData("main|scnd|train|local|radiovolume|radiochannel|dynamicbrake")] + [InlineData("main|dynamicbrake")] + [InlineData("dynamicbrake")] + [InlineData("yes yes yes yes")] + [InlineData("no yes no YES")] + [InlineData("no no no no")] + public void A_uartfeature_line_nobody_changed_stays_as_the_file_has_it(string value) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, $"uartfeature {value}\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Equal($"uartfeature {value}", LineFor(installation, "uartfeature")); + } + + [Theory] + [InlineData("uartfeature none", "uartfeature no no no no")] + [InlineData("uartfeature", "uartfeature no no no no")] + // Short of its four words, the simulator would read the next key for the rest. + [InlineData("uartfeature yes yes", "uartfeature main|scnd|")] + [InlineData("uartfeature nonsense", "uartfeature no no no no")] + public void A_uartfeature_line_not_every_simulator_reads_whole_is_put_right(string line, string written) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line + "\nmaxcabtexturesize 2048\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Equal(written, LineFor(installation, "uartfeature")); + Assert.Equal("maxcabtexturesize 2048", LineFor(installation, "maxcabtexturesize")); + } + + [Theory] + [InlineData("yes yes yes yes", "uartfeature scnd|train|local|")] + [InlineData("main|dynamicbrake", "uartfeature dynamicbrake|")] + public void A_uart_feature_taken_off_rewrites_the_line_in_the_pipe_form(string value, string written) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, $"uartfeature {value}\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartMain = false; + + installation.SettingsStore.Save(); + + Assert.Equal(written, LineFor(installation, "uartfeature")); + } + + [Fact] + public void The_uart_interval_is_shown_within_what_its_box_holds() + { + Assert.Equal(0.05m, Uart.ShownUpdateTime(0.05)); + // 1e30 is more than a decimal can hold: the window died on opening. + Assert.Equal(10m, Uart.ShownUpdateTime(1e30)); + Assert.Equal(10m, Uart.ShownUpdateTime(double.PositiveInfinity)); + Assert.Equal((decimal)Uart.DefaultUpdateTime, Uart.ShownUpdateTime(double.NaN)); + Assert.Equal(0m, Uart.ShownUpdateTime(-1)); + } + + [Theory] + [InlineData("mousescale 1.5 0.5", false, true, "mousescale 1.5 -0.5")] + [InlineData("mousescale 1.5 0.5", true, false, "mousescale -1.5 0.5")] + [InlineData("mousescale -1.5 -0.5", false, false, "mousescale 1.5 0.5")] + [InlineData("mousescale 2.5 0.123456", false, true, "mousescale 2.5 -0.123456")] + // Further than the slider goes. + [InlineData("mousescale 12 0.5", true, false, "mousescale -12 0.5")] + public void A_mouse_scale_keeps_its_numbers_when_an_axis_is_inverted( + string line, bool invertX, bool invertY, string written) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line + "\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.InvertMouseHorizontal = invertX; + installation.SettingsStore.Settings.InvertMouseVertical = invertY; + + installation.SettingsStore.Save(); + + Assert.Equal(written, LineFor(installation, "mousescale")); + } + + [Fact] + public void A_mouse_scale_nobody_changed_stays_as_the_file_has_it() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "mousescale 1.5 0.5\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Equal("mousescale 1.5 0.5", LineFor(installation, "mousescale")); + } + + [Fact] + public void Moving_the_sensitivity_replaces_the_scale_it_stood_for() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "mousescale 1.5 0.5\n")); + installation.SettingsStore.Load(); + Assert.Equal(2, installation.SettingsStore.Settings.CursorSensitivity); + installation.SettingsStore.Settings.CursorSensitivity = 3; + + installation.SettingsStore.Save(); + + Assert.Equal("mousescale 3 0.5", LineFor(installation, "mousescale")); + } + + [Fact] + public void The_default_mouse_scale_is_written_as_before() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Equal("mousescale 2 0.2", LineFor(installation, "mousescale")); + } + + // ── A file that could not be read ──────────────────────────────────────── + + [Fact] + public void A_file_that_could_not_be_read_is_not_saved_over() + { + const string held = "width 1920\nsomethingexotic 42\n"; + var files = new InMemoryFileSystem().WithFile(UserIni, held).FailReading(UserIni); + var installation = WithHome(files); + installation.SettingsStore.Load(); + Assert.True(installation.SettingsStore.LoadFailed); + Assert.Equal(1280, installation.SettingsStore.Settings.Width); + + installation.SettingsStore.Save(); + installation.SettingsStore.CaptureAndSave(); + + files.AllowReading(UserIni); + Assert.Equal(held, files.ReadAllText(UserIni)); + Assert.Contains("not saved", installation.Log.Text); + // The screen must not say "saved" over this. + Assert.True(installation.SettingsStore.LastSaveFailed); + + installation.SettingsStore.Load(); + installation.SettingsStore.Save(); + Assert.False(installation.SettingsStore.LastSaveFailed); + } + + [Fact] + public void A_profile_of_the_settings_is_not_written_from_defaults_either() + { + const string profile = "/home/kolejarz/profile.ini"; + var files = new InMemoryFileSystem().WithFile(UserIni, "width 1920\n").FailReading(UserIni); + var installation = WithHome(files); + installation.SettingsStore.Load(); + + installation.SettingsStore.SaveTo(profile); + + Assert.False(files.FileExists(profile)); + } + + [Fact] + public void A_read_that_works_again_lets_the_settings_be_saved() + { + var files = new InMemoryFileSystem().WithFile(UserIni, "width 1920\n").FailReading(UserIni); + var installation = WithHome(files); + installation.SettingsStore.Load(); + + files.AllowReading(UserIni); + installation.SettingsStore.Load(); + Assert.False(installation.SettingsStore.LoadFailed); + installation.SettingsStore.Settings.Height = 1080; + installation.SettingsStore.Save(); + + Assert.Equal("width 1920", LineFor(installation, "width")); + Assert.Equal("height 1080", LineFor(installation, "height")); + } + + [Fact] + public void A_shipped_ini_that_could_not_be_read_leaves_no_file_of_defaults_in_its_way() + { + // The user's own file, once there, is the one read from then on. + var files = new InMemoryFileSystem() + .WithFile(TestInstallation.At("eu07.ini"), "width 800\n") + .FailReading(TestInstallation.At("eu07.ini")); + var installation = WithHome(files); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.False(files.FileExists(UserIni)); + } + + // ── endconfig ──────────────────────────────────────────────────────────── + + [Fact] + public void A_key_the_file_lacks_goes_before_endconfig() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "width 1920\nendconfig\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + var lines = SavedLines(installation).Split('\n').ToList(); + int end = lines.IndexOf("endconfig"); + Assert.True(end > lines.IndexOf("fullscreen no") && lines.IndexOf("fullscreen no") >= 0); + Assert.DoesNotContain(lines.Skip(end + 1), line => line.Trim().Length > 0); + } + + [Fact] + public void A_key_an_older_save_left_past_endconfig_moves_before_it() + { + // Set there by this starter's own earlier save, and never read by the simulator. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "width 1920\nendconfig\nfieldofview 60\nnotes about this file\n")); + var store = installation.SettingsStore; + store.Load(); + Assert.Equal(60, store.Settings.FieldOfView); + Assert.Contains(new StarterNG.Application.IniChange("fieldofview", null, "60"), store.Changes(store.Settings)); + + store.Save(); + + var lines = SavedLines(installation).Split('\n').ToList(); + Assert.True(lines.IndexOf("fieldofview 60") < lines.IndexOf("endconfig")); + Assert.Single(lines, "fieldofview 60"); + // What is no key of the starter's stays where the simulator does not read it. + Assert.True(lines.IndexOf("notes about this file") > lines.IndexOf("endconfig")); + } + + [Fact] + public void A_disabled_uart_past_endconfig_is_taken_out_rather_than_moved() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "endconfig\nuart COM3 57600 0.1\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.UartEnabled = false; + + installation.SettingsStore.Save(); + + var lines = installation.Files.ReadAllText(UserIni).Replace("\r\n", "\n").Split('\n'); + Assert.DoesNotContain(lines, l => l.StartsWith("uart ")); + } + + // ── No bare keys ───────────────────────────────────────────────────────── + + [Fact] + public void Saving_the_default_settings_leaves_no_simulator_key_bare() + { + // A bare key takes the next word of the file for its value: uartfeature the + // next four, which is how keys after it went unread. + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Empty(BareSimulatorKeys(installation)); + } + + [Fact] + public void Everything_emptied_leaves_no_simulator_key_bare() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + var s = installation.SettingsStore.Settings; + s.LanguageCode = " "; + s.UartEnabled = true; + s.UartPort = " "; + s.UartTune = " "; + s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = s.UartRadioVolume = s.UartRadioChannel = false; + s.ExecutablePath = ""; + s.LastScenery = ""; + foreach (var key in SimulatorKeys.All) + s.Extra[key.Key] = " "; + + installation.SettingsStore.Save(); + + Assert.Empty(BareSimulatorKeys(installation)); + } + + [Theory] + [InlineData("uart")] + [InlineData("uarttune")] + [InlineData("uartfeature")] + [InlineData("mousescale")] + [InlineData("fullscreen")] + [InlineData("width")] + [InlineData("screenshotsdir")] + [InlineData("fpslimit")] + [InlineData("gfx.angleplatform")] + public void A_key_the_file_itself_leaves_bare_does_not_stay_so(string key) + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, $"{key}\nmaxcabtexturesize 2048\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Empty(BareSimulatorKeys(installation)); + Assert.Equal("maxcabtexturesize 2048", LineFor(installation, "maxcabtexturesize")); + } + [Fact] public void Keys_the_starter_does_not_manage_survive_a_round_trip() { @@ -197,6 +756,160 @@ public class SettingsStoreTests installation.SettingsPaths.UserConfigPath()); } + [Theory] + [InlineData("144")] + [InlineData("360")] + [InlineData("59.94")] + public void A_frame_rate_limit_the_slider_once_cut_to_100_survives_a_save(string limit) + { + // The simulator takes any rate above zero. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, $"fpslimit {limit}\n")); + installation.SettingsStore.Load(); + Assert.True(installation.SettingsStore.Settings.FpsLimitEnabled); + + installation.SettingsStore.Save(); + + Assert.Contains($"fpslimit {limit}", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_frame_rate_limit_of_zero_is_no_limit() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fpslimit 0\n")); + installation.SettingsStore.Load(); + + Assert.False(installation.SettingsStore.Settings.FpsLimitEnabled); + } + + [Theory] + [InlineData("1")] + [InlineData("0.5")] + [InlineData("60")] + public void A_reflection_rate_below_the_old_slider_survives_a_save(string rate) + { + // The game's own eu07.ini has 1; the slider's floor of 5 raised it on any save. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, $"gfx.reflections.framerate {rate}\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains($"gfx.reflections.framerate {rate}\n", SavedLines(installation)); + } + + [Fact] + public void Without_a_reflection_rate_the_simulators_own_is_written() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("gfx.reflections.framerate 0.003333", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void The_shadow_range_is_the_third_number_of_shadowtune() + { + // map size, unread, projection range, unread - as the game's eu07.ini says. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "shadowtune 1024 100 150 300\n")); + installation.SettingsStore.Load(); + Assert.Equal(1024, installation.SettingsStore.Settings.ShadowMapResolution); + Assert.Equal(150, installation.SettingsStore.Settings.ShadowProjectionRange); + + installation.SettingsStore.Settings.ShadowProjectionRange = 200; + installation.SettingsStore.Save(); + + Assert.Contains("shadowtune 1024 100 200 300", installation.Files.ReadAllText(UserIni)); + } + + [Theory] + [InlineData(1024, 75)] + [InlineData(2048, 75)] + [InlineData(4096, 150)] + [InlineData(8192, 300)] + public void The_shadow_range_floor_follows_the_map_size(int mapSize, int floor) => + Assert.Equal(floor, SimulatorSettings.ShadowRangeFloor(mapSize)); + + [Theory] + [InlineData(150, 1024, false, 150)] + [InlineData(150, 4096, false, 300)] + [InlineData(150, 4096, true, 150)] + [InlineData(100, 8192, false, 1200)] + [InlineData(400, 8192, false, 1600)] + public void Shadows_reach_as_far_as_the_renderer_stretches_them(int range, int mapSize, bool legacy, int reach) => + Assert.Equal(reach, SimulatorSettings.ShadowReach(range, mapSize, legacy)); + + [Theory] + [InlineData("anisotropicfiltering 12")] + [InlineData("multisampling 4")] + public void A_value_the_slider_does_not_offer_survives_a_save(string line) + { + // x16 multisampling and an odd anisotropy are the simulator's to take. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line + "\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains(line, installation.Files.ReadAllText(UserIni)); + } + + [Theory] + [InlineData("default")] + [InlineData("simpleshader")] + [InlineData("off")] + public void A_renderer_word_the_simulator_reads_as_another_stays_until_changed(string word) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, $"gfxrenderer {word}\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains($"gfxrenderer {word}\n", SavedLines(installation)); + } + + [Theory] + [InlineData(0, "full")] + [InlineData(1, "legacy")] + [InlineData(2, "simple")] + [InlineData(3, "experimental")] + public void A_chosen_renderer_is_saved_as_the_simulator_reads_it(int engine, string saved) + { + // application.cpp knows full (or default), experimental and simple; every + // other word - "simpleshader" and "off" from older starters - is legacy. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "gfxrenderer simpleshader\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.RenderEngine = engine == 1 ? 0 : engine; + installation.SettingsStore.Save(); + + installation.SettingsStore.Settings.RenderEngine = engine; + installation.SettingsStore.Save(); + + Assert.Contains($"gfxrenderer {saved}\n", SavedLines(installation)); + } + + [Fact] + public void A_field_of_view_down_to_ten_degrees_survives_a_save() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 10\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("fieldofview 10", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_silenced_radio_stays_silent() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "sound.volume.radio 0\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("sound.volume.radio 0\n", SavedLines(installation)); + } + /// Stands in for the settings screen holding an unsaved edit. private sealed class WidthCapture : StarterNG.Application.ISettingsCapture { @@ -206,4 +919,280 @@ public class SettingsStoreTests public void CaptureInto(SimulatorSettings settings) => settings.Width = _width; } + + [Fact] + public void A_simulator_key_nobody_touched_stays_out_of_the_file() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.DoesNotContain("basedrawrange", installation.Files.ReadAllText(UserIni)); + Assert.DoesNotContain("ui.scale", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_simulator_key_keeps_its_value_and_comment_through_a_save() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "basedrawrange 15000\t// further than the slider goes\n")); + installation.SettingsStore.Load(); + + Assert.Equal("15000", installation.SettingsStore.Settings.Extra["basedrawrange"]); + + installation.SettingsStore.Save(); + + Assert.Contains("basedrawrange 15000\t// further than the slider goes", + installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_changed_simulator_key_is_written() + { + var installation = WithHome(); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.Extra["shakefactor"] = + SimulatorKeys.All.Single(k => k.Key == "shakefactor").FormatNumber(0.5); + + installation.SettingsStore.Save(); + + Assert.Contains("shakefactor 0.5 0.5 0.5", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void An_emptied_word_is_taken_out_rather_than_left_bare() + { + // A bare "screenshotsdir" would swallow the next line's key as its value. + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "screenshotsdir shots\nfullscreen yes\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.Extra["screenshotsdir"] = ""; + + installation.SettingsStore.Save(); + + string text = installation.Files.ReadAllText(UserIni); + Assert.Contains("// screenshotsdir shots", text); + Assert.Contains("fullscreen yes", text); + } + + // ── A save changes only what was changed ───────────────────────────────── + + [Theory] + [InlineData("fieldofview 80")] + [InlineData("dynamiclights 9")] + [InlineData("anisotropicfiltering 16.0\t// as far as it goes")] + [InlineData("gfx.smoke.fidelity 1.5")] + public void A_line_nobody_changed_stays_as_the_file_has_it(string line) + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, line + "\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains(line + "\n", SavedLines(installation)); + } + + [Fact] + public void A_changed_value_replaces_the_files() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 80\n")); + installation.SettingsStore.Load(); + installation.SettingsStore.Settings.FieldOfView = 50; + + installation.SettingsStore.Save(); + + Assert.Contains("fieldofview 50", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_key_the_file_lacks_is_written_out() + { + // So the simulator and the screen go by the same value. + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 45\n")); + installation.SettingsStore.Load(); + + installation.SettingsStore.Save(); + + Assert.Contains("fullscreen no", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_save_reports_what_it_would_change_before_making_it() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "fullscreen no\nfieldofview 80\ngfx.postfx.tonemapping yes\n")); + var store = installation.SettingsStore; + store.Load(); + store.Settings.Fullscreen = true; + + var changes = store.Changes(store.Settings); + + Assert.Contains(new StarterNG.Application.IniChange("fullscreen", "no", "yes"), changes); + Assert.Contains(new StarterNG.Application.IniChange("gfx.postfx.tonemapping", "yes", null), changes); + Assert.DoesNotContain(changes, change => change.Key == "fieldofview"); + Assert.Contains("fullscreen no", installation.Files.ReadAllText(UserIni)); + } + + /// Stands in for a screen whose combo box cannot show the file's shadow rank. + private sealed class ShadowRankScreen : StarterNG.Application.ISettingsCapture + { + public int Shown = 3; + + public void CaptureInto(SimulatorSettings settings) => settings.ShadowRankCutoff = Shown; + + public IReadOnlyDictionary ShownUnlikeSettings { get; } = + new Dictionary + { + ["gfx.shadow.rank.cutoff"] = new("3", "5") + }; + } + + [Fact] + public void A_value_the_screen_could_not_show_survives_until_moved() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "gfx.shadow.rank.cutoff 5\n")); + var store = installation.SettingsStore; + var screen = new ShadowRankScreen(); + store.RegisterCapture(screen); + store.Load(); + + store.CaptureAndSave(); + Assert.Contains("gfx.shadow.rank.cutoff 5", installation.Files.ReadAllText(UserIni)); + + screen.Shown = 2; + store.CaptureAndSave(); + Assert.Contains("gfx.shadow.rank.cutoff 2", installation.Files.ReadAllText(UserIni)); + + // Moved back to what it first showed, it is a choice like any other. + screen.Shown = 3; + store.CaptureAndSave(); + Assert.Contains("gfx.shadow.rank.cutoff 3", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_section_put_back_takes_the_simulators_own_values_for_its_keys_alone() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, + "fieldofview 60\nmaxtexturesize 1024\nshadowtune 4096 250 500 300\nbasedrawrange 4000\n")); + var store = installation.SettingsStore; + store.Load(); + + var reset = store.WithDefaults(store.Settings, new[] { "fieldofview", "shadowtune", "basedrawrange" }); + + Assert.Equal(45, reset.FieldOfView); + Assert.Equal(2048, reset.ShadowMapResolution); + Assert.Equal(250, reset.ShadowProjectionRange); + Assert.False(reset.Extra.ContainsKey("basedrawrange")); + Assert.Equal(1024, reset.MaxTextureSize); + // The settings themselves wait for the screen's save. + Assert.Equal(60, store.Settings.FieldOfView); + } + + // ── What the screen shows otherwise ────────────────────────────────────── + + [Fact] + public void Settings_shown_as_they_are_held_have_nothing_unlike() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 60\nbasedrawrange 4000\n")); + var store = installation.SettingsStore; + store.Load(); + + Assert.Empty(store.ShownUnlike(store.Settings.Copy())); + } + + [Fact] + public void A_value_shown_otherwise_comes_with_both_sides_under_any_spelling_of_its_key() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 60\n")); + var store = installation.SettingsStore; + store.Load(); + var shown = store.Settings.Copy(); + shown.FieldOfView = 45; + + var unlike = store.ShownUnlike(shown); + + Assert.Equal("fieldofview", Assert.Single(unlike).Key); + Assert.Equal(new StarterNG.Application.ShownValue("45", "60"), unlike["FIELDOFVIEW"]); + } + + [Fact] + public void A_key_on_one_side_alone_shows_as_null_on_the_other() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "basedrawrange 4000\n")); + var store = installation.SettingsStore; + store.Load(); + + var added = store.Settings.Copy(); + added.Extra["heightbase"] = "2"; + Assert.Equal(new StarterNG.Application.ShownValue("2", null), store.ShownUnlike(added)["heightbase"]); + + var taken = store.Settings.Copy(); + taken.Extra.Remove("basedrawrange"); + Assert.Equal(new StarterNG.Application.ShownValue(null, "4000"), store.ShownUnlike(taken)["basedrawrange"]); + } + + // ── Applying a profile ─────────────────────────────────────────────────── + + private static readonly string Profile = Path.Combine("/home/kolejarz", "profiles", "wieczor.ini"); + + [Fact] + public void A_readable_profile_is_loaded() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "fieldofview 60\n").WithFile(Profile, "fieldofview 50\n")); + var store = installation.SettingsStore; + store.Load(); + + Assert.True(store.TryLoadProfile(Profile)); + + Assert.Equal(50, store.Settings.FieldOfView); + Assert.False(store.LoadFailed); + } + + [Fact] + public void A_missing_profile_leaves_the_settings_as_they_are() + { + var installation = WithHome(new InMemoryFileSystem().WithFile(UserIni, "fieldofview 60\n")); + var store = installation.SettingsStore; + store.Load(); + store.Settings.FieldOfView = 70; + + Assert.False(store.TryLoadProfile(Profile)); + + Assert.Equal(70, store.Settings.FieldOfView); + } + + [Fact] + public void An_unreadable_profile_leaves_the_real_settings_not_defaults_to_save_over_them() + { + var installation = WithHome(new InMemoryFileSystem() + .WithFile(UserIni, "fieldofview 60\n").WithFile(Profile, "fieldofview 50\n").FailReading(Profile)); + var store = installation.SettingsStore; + store.Load(); + + Assert.False(store.TryLoadProfile(Profile)); + + Assert.False(store.LoadFailed); + Assert.Equal(60, store.Settings.FieldOfView); + store.Save(); + Assert.False(store.LastSaveFailed); + Assert.Contains("fieldofview 60", installation.Files.ReadAllText(UserIni)); + } + + [Fact] + public void A_missing_profile_gives_settings_that_could_not_be_read_another_try() + { + var files = new InMemoryFileSystem().WithFile(UserIni, "fieldofview 60\n").FailReading(UserIni); + var installation = WithHome(files); + var store = installation.SettingsStore; + store.Load(); + Assert.True(store.LoadFailed); + + files.AllowReading(UserIni); + Assert.False(store.TryLoadProfile(Profile)); + + Assert.False(store.LoadFailed); + Assert.Equal(60, store.Settings.FieldOfView); + } } diff --git a/StarterNG.Tests/StepsTests.cs b/StarterNG.Tests/StepsTests.cs new file mode 100644 index 0000000..c46fc24 --- /dev/null +++ b/StarterNG.Tests/StepsTests.cs @@ -0,0 +1,42 @@ +using StarterNG.Domain.Settings; + +namespace StarterNG.Tests; + +public class StepsTests +{ + [Fact] + public void A_value_among_the_steps_leaves_them_as_they_are() + { + Assert.Same(Steps.FrameRateLimits, Steps.With(Steps.FrameRateLimits, 144)); + } + + [Fact] + public void A_value_between_the_steps_joins_them_in_order() + { + var steps = Steps.With(Steps.FrameRateLimits, 58); + + Assert.Equal(Steps.FrameRateLimits.Length + 1, steps.Count); + Assert.Equal(new double[] { 50, 58, 60 }, steps.Skip(Steps.IndexOf(steps, 50)).Take(3)); + } + + [Fact] + public void Values_past_either_end_join_at_that_end() + { + Assert.Equal(0.5, Steps.With(Steps.Anisotropy, 0.5)[0]); + Assert.Equal(500, Steps.With(Steps.FrameRateLimits, 500)[^1]); + } + + [Fact] + public void The_frame_rate_limits_start_with_none() + { + Assert.Equal(new double[] { 0, 10, 20 }, Steps.With(Steps.FrameRateLimits, 10).Take(3)); + } + + [Fact] + public void A_step_is_found_from_its_rounding_in_the_file() + { + // 1/300 is written as 0.003333. + Assert.Equal(0, Steps.IndexOf(Steps.ReflectionUpdates, 0.003333)); + Assert.Equal(-1, Steps.IndexOf(Steps.ReflectionUpdates, 0.0033)); + } +} diff --git a/StarterNG.Tests/WineConfigLinkTests.cs b/StarterNG.Tests/WineConfigLinkTests.cs new file mode 100644 index 0000000..bb2302c --- /dev/null +++ b/StarterNG.Tests/WineConfigLinkTests.cs @@ -0,0 +1,209 @@ +using StarterNG.Infrastructure; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class WineConfigLinkTests +{ + // Rooted the way the host roots a path: the link is made to the folder's full + // path, and on a Windows host "/home" alone is not one. Wine itself only runs off + // Windows, but what is decided here is the same wherever these tests run. + private static readonly string Home = Path.GetFullPath("/home/kolejarz"); + private static readonly string Config = Path.GetFullPath(Home + "/.config/MaSzyna"); + private static readonly string Roaming = Home + "/.wine/drive_c/users/kolejarz/AppData/Roaming"; + private static readonly string PrefixFolder = Roaming + "/MaSzyna"; + + private static (WineConfigLink Link, TestInstallation Installation) Rig(Action? arrange = null) + { + var files = new InMemoryFileSystem(caseSensitive: true) + .WithFile(Config + "/eu07.ini", "fpslimit 30\n"); + arrange?.Invoke(files); + var installation = new TestInstallation(files); + installation.Environment.With("HOME", Home).With("USER", "kolejarz"); + return (new WineConfigLink(files, installation.Environment, installation.SettingsPaths, installation.Log), + installation); + } + + [Fact] + public void The_prefixs_own_folder_is_kept_aside_and_linked_to_the_starters() + { + var (link, installation) = Rig(files => files + .WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n") + .WithFile(PrefixFolder + "/eu07_input-uart.ini", "port COM3\n")); + + link.Ensure(); + + var files = installation.Files; + Assert.Equal(Config, files.LinkTarget(PrefixFolder)); + Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini")); + // What only the prefix had comes along; the starter's own eu07.ini stays. + Assert.Equal("port COM3\n", files.ReadAllText(Config + "/eu07_input-uart.ini")); + Assert.Equal("fpslimit 30\n", files.ReadAllText(Config + "/eu07.ini")); + Assert.Contains("now links to", installation.Log.Text); + } + + [Fact] + public void A_prefix_already_linked_is_left_alone() + { + var (link, installation) = Rig(files => files.WithDirectory(PrefixFolder)); + link.Ensure(); + + link.Ensure(); + + Assert.Equal(Config, installation.Files.LinkTarget(PrefixFolder)); + Assert.False(installation.Files.DirectoryExists(PrefixFolder + ".before-starter-2")); + } + + [Fact] + public void A_link_of_the_players_own_is_not_touched() + { + var (link, installation) = Rig(files => + { + files.WithDirectory(Roaming); + files.CreateDirectoryLink(PrefixFolder, "/mnt/shared/MaSzyna"); + }); + + link.Ensure(); + + Assert.Equal("/mnt/shared/MaSzyna", installation.Files.LinkTarget(PrefixFolder)); + Assert.Contains("left as it is", installation.Log.Text); + } + + [Fact] + public void Without_a_prefix_nothing_is_made() + { + var (link, installation) = Rig(); + + link.Ensure(); + + Assert.False(installation.Files.DirectoryExists(Home + "/.wine")); + Assert.Contains("no prefix", installation.Log.Text); + } + + [Fact] + public void The_prefix_named_in_wineprefix_is_the_one_linked() + { + string prefix = Path.GetFullPath("/games/wine-maszyna"); + var (link, installation) = Rig(files => files + .WithDirectory(prefix + "/drive_c/users/kolejarz/AppData/Roaming") + .WithDirectory(Roaming)); + installation.Environment.With("WINEPREFIX", prefix); + + link.Ensure(); + + Assert.Equal(Config, installation.Files.LinkTarget(prefix + "/drive_c/users/kolejarz/AppData/Roaming/MaSzyna")); + Assert.Null(installation.Files.LinkTarget(PrefixFolder)); + } + + [Fact] + public void An_older_backup_is_not_overwritten() + { + var (link, installation) = Rig(files => files + .WithFile(PrefixFolder + ".before-starter/eu07.ini", "first\n") + .WithFile(PrefixFolder + "/eu07.ini", "second\n")); + + link.Ensure(); + + Assert.Equal("first\n", installation.Files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini")); + Assert.Equal("second\n", installation.Files.ReadAllText(PrefixFolder + ".before-starter-2/eu07.ini")); + } + + [Fact] + public void An_older_wine_keeps_application_data_where_xp_did() + { + string applicationData = Home + "/.wine/drive_c/users/kolejarz/Application Data"; + var (link, installation) = Rig(files => files.WithDirectory(applicationData)); + + link.Ensure(); + + Assert.Equal(Config, installation.Files.LinkTarget(applicationData + "/MaSzyna")); + } + + [Fact] + public void The_only_profile_is_used_when_the_user_name_differs() + { + string profile = Home + "/.wine/drive_c/users/steamuser/AppData/Roaming"; + var (link, installation) = Rig(files => files + .WithDirectory(Home + "/.wine/drive_c/users/Public") + .WithDirectory(profile)); + + link.Ensure(); + + Assert.Equal(Config, installation.Files.LinkTarget(profile + "/MaSzyna")); + } + + [Fact] + public void A_link_that_cannot_be_made_leaves_the_prefix_as_it_was() + { + var (link, installation) = Rig(files => files + .WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n") + .WithFile(PrefixFolder + "/eu07_input-uart.ini", "port COM3\n")); + var files = installation.Files; + files.LinksFail = true; + + link.Ensure(); + + Assert.Null(files.LinkTarget(PrefixFolder)); + Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + "/eu07.ini")); + Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter")); + // Nothing carried over stays behind for a move that did not happen. + Assert.False(files.FileExists(Config + "/eu07_input-uart.ini")); + Assert.Equal("fpslimit 30\n", files.ReadAllText(Config + "/eu07.ini")); + Assert.Contains("linking the settings", installation.Log.Text); + } + + [Fact] + public void Attempts_that_fail_do_not_pile_up_backups() + { + var (link, installation) = Rig(files => files.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n")); + var files = installation.Files; + files.LinksFail = true; + + link.Ensure(); + link.Ensure(); + link.Ensure(); + + Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter")); + Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter-2")); + + files.LinksFail = false; + link.Ensure(); + + Assert.Equal(Config, files.LinkTarget(PrefixFolder)); + Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini")); + Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter-2")); + } + + [Fact] + public void A_copy_that_fails_half_way_takes_back_what_it_had_copied() + { + var (link, installation) = Rig(files => files + .WithFile(PrefixFolder + "/a.ini", "first\n") + .WithFile(PrefixFolder + "/b.ini", "second\n") + // It is read from where the folder has been moved to by then. + .FailReading(PrefixFolder + ".before-starter/b.ini")); + var files = installation.Files; + + link.Ensure(); + + Assert.Null(files.LinkTarget(PrefixFolder)); + Assert.Equal("first\n", files.ReadAllText(PrefixFolder + "/a.ini")); + Assert.False(files.FileExists(Config + "/a.ini")); + Assert.False(files.DirectoryExists(PrefixFolder + ".before-starter")); + } + + [Fact] + public void A_folder_that_cannot_be_put_back_is_said_to_be_where_it_is() + { + var (link, installation) = Rig(files => files.WithFile(PrefixFolder + "/eu07.ini", "fpslimit 0\n")); + var files = installation.Files; + files.LinksFail = true; + // In the way of the move back: made by whatever broke the link, say. + files.MoveFails = path => path.EndsWith("/Roaming/MaSzyna"); + + link.Ensure(); + + Assert.Equal("fpslimit 0\n", files.ReadAllText(PrefixFolder + ".before-starter/eu07.ini")); + Assert.Contains("stays at", installation.Log.Text); + } +} diff --git a/StarterNG/Application/GraphicsGroupLevels.cs b/StarterNG/Application/GraphicsGroupLevels.cs new file mode 100644 index 0000000..4d7937a --- /dev/null +++ b/StarterNG/Application/GraphicsGroupLevels.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StarterNG.Domain.Settings; + +namespace StarterNG.Application; + +/// Levels of detail part by part: what a level holds for one , the rest as it is. +public sealed class GraphicsGroupLevels +{ + private readonly SettingsStore _store; + + public GraphicsGroupLevels(SettingsStore store) => _store = store; + + /// + /// The level each group is at in , null where it is at none. At + /// Ultra the level under it fits too, as it leaves alone what only Ultra sets: the highest counts. + /// + public IReadOnlyDictionary LevelsOf(SimulatorSettings settings) + { + var now = _store.Render(settings); + var rendered = GraphicsPreset.All.Select(level => (Level: level, Keys: RenderAt(level, settings))).ToList(); + return GraphicsGroup.All.ToDictionary(group => group, group => + (GraphicsPreset?)rendered.LastOrDefault(level => Same(group, level.Keys, now)).Level); + } + + /// + /// A copy of with the group at . What only + /// an end of the scale sets goes back to the simulator's own on leaving that end. + /// + public SimulatorSettings Apply(GraphicsGroup group, GraphicsPreset level, SimulatorSettings settings) + { + var current = LevelsOf(settings)[group]; + var source = settings.Copy(); + if (level.Extras is null && current?.Extras is not null) + GraphicsExtras.None.ApplyTo(source); + if (level.Top is null && current?.Top is not null) + GraphicsTop.None.ApplyTo(source); + level.ApplyTo(source); + return _store.WithKeysOf(settings, source, group.Keys); + } + + private IReadOnlyDictionary RenderAt(GraphicsPreset level, SimulatorSettings settings) + { + var copy = settings.Copy(); + level.ApplyTo(copy); + return _store.Render(copy); + } + + private static bool Same(GraphicsGroup group, IReadOnlyDictionary one, IReadOnlyDictionary other) => + group.Keys.All(key => string.Equals(ValueIn(one, key), ValueIn(other, key), StringComparison.Ordinal)); + + // A key the file does not set is at the simulator's own value. + private static string? ValueIn(IReadOnlyDictionary keys, string key) => + keys.TryGetValue(key, out string? value) ? value : SimulatorKeys.All.FirstOrDefault(known => known.Key == key)?.Default; +} diff --git a/StarterNG/Application/SettingsStore.cs b/StarterNG/Application/SettingsStore.cs index bb56352..ca92875 100644 --- a/StarterNG/Application/SettingsStore.cs +++ b/StarterNG/Application/SettingsStore.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using StarterNG.Application.Abstractions; using StarterNG.Classes; @@ -12,8 +14,20 @@ namespace StarterNG.Application; public interface ISettingsCapture { void CaptureInto(SimulatorSettings settings); + + /// + /// Keys the screen shows otherwise than the settings hold them - a value its + /// control cannot show - by key, in the file's terms. + /// + IReadOnlyDictionary ShownUnlikeSettings => new Dictionary(); } +/// A key as the screen shows it, and as the settings held it when it was put there; null for absent. +public sealed record ShownValue(string? Shown, string? Held); + +/// A key a save changes: its value in the file before and after; null where it has none. +public sealed record IniChange(string Key, string? Before, string? After); + public sealed class SettingsStore { private readonly IFileSystem _files; @@ -29,6 +43,11 @@ public sealed class SettingsStore private DateTime _configAgeUtc = DateTime.MinValue; private ISettingsCapture? _capture; + // The settings in the file's terms when it was last read or written. A key + // they still put the same way was not changed here, so the file keeps it as + // it has it: a value outside what the starter offers, its spacing, its comment. + private IReadOnlyDictionary _synced = new Dictionary(); + public SettingsStore(IFileSystem files, IClock clock, IDiagnosticsLog log, SettingsPaths paths, SettingsSerializer serializer, ExecutableLocator executables) { @@ -45,6 +64,15 @@ public sealed class SettingsStore public string LoadedFrom { get; private set; } = string.Empty; + /// + /// The last read failed, so holds defaults, not the file's. + /// Nothing is saved until a read works: that would put them in its place. + /// + public bool LoadFailed { get; private set; } + + /// The last save did not reach the file; the log says why. + public bool LastSaveFailed { get; private set; } + public string SavePath => _savePath; public void RegisterCapture(ISettingsCapture capture) => _capture = capture; @@ -57,6 +85,16 @@ public sealed class SettingsStore LoadFrom(source); } + /// A player with no settings of their own yet starts at High, in the file at once so the simulator goes by it too. + public void StartAtHighWithoutOwnSettings() + { + if (LoadFailed || _files.FileExists(_savePath)) + return; + + GraphicsPreset.High.ApplyTo(Settings); + Save(); + } + public void LoadFrom(string path) { try @@ -64,17 +102,38 @@ public sealed class SettingsStore _config = _files.FileExists(path) ? ConfigFile.Parse(_files.ReadAllText(path, _encoding)) : new ConfigFile(); + LoadFailed = false; } catch (Exception ex) { _log.Log($"reading {path}", ex); _config = new ConfigFile(); + LoadFailed = true; } _serializer.Read(Settings, _config); + _synced = Render(Settings); TouchConfigAge(); } + /// + /// Reads a profile into the settings; true when it read cleanly. A read that failed + /// would leave defaults to be saved over the real settings, so those are read again. + /// + public bool TryLoadProfile(string path) + { + if (_files.FileExists(path)) + { + LoadFrom(path); + if (!LoadFailed) + return true; + } + + if (LoadFailed) + Load(); + return false; + } + public void CaptureAndSave() { _capture?.CaptureInto(Settings); @@ -91,7 +150,21 @@ public sealed class SettingsStore public void SaveTo(string path) { - _serializer.Write(Settings, _config); + LastSaveFailed = true; + if (LoadFailed) + { + _log.Log($"{path} not saved: the settings could not be read, what is held is defaults"); + return; + } + + foreach (var change in Changes(Settings)) + { + if (change.After is null) + _config.Remove(change.Key); + else + _config.Set(change.Key, change.After); + } + _synced = Render(Settings); try { @@ -99,8 +172,9 @@ public sealed class SettingsStore if (!string.IsNullOrEmpty(directory)) _files.CreateDirectory(directory); - _files.WriteAllText(path, _config.ToText(), _encoding); + _files.WriteAllBytesAtomic(path, _encoding.GetBytes(_config.ToText())); TouchConfigAge(); + LastSaveFailed = false; } catch (Exception ex) { @@ -108,6 +182,116 @@ public sealed class SettingsStore } } + /// Every key as would put it, in the order they are written. + public IReadOnlyDictionary Render(SimulatorSettings settings) + { + var config = new ConfigFile(); + _serializer.Write(settings, config); + var keys = new OrderedDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (key, value) in config.Entries()) + keys[key] = value; + return keys; + } + + /// + /// What saving would change in the file: the keys + /// changed since it was read, those it lacks, and the ones no simulator reads. + /// A key the screen shows otherwise than the file has it is left alone until + /// moved. One the file leaves bare or past endconfig counts as lacking. + /// + public IReadOnlyList Changes(SimulatorSettings settings) + { + var now = Render(settings); + var shown = _capture?.ShownUnlikeSettings ?? new Dictionary(); + var changes = new List(); + + foreach (string key in SettingsSerializer.ObsoleteKeys) + Add(key, null); + foreach (string key in SettingsSerializer.FixedLengthKeys.Where(key => !now.ContainsKey(key))) + Add(key, null); + foreach (string key in SettingsSerializer.OptionalKeys.Concat(SimulatorKeys.All.Select(key => key.Key))) + if (!now.ContainsKey(key) && _config.GetRaw(key) is { Length: 0 }) + Add(key, null); + foreach (var (key, value) in now) + if (!Kept(key, value)) + Add(key, value); + foreach (string key in _synced.Keys.Where(key => !now.ContainsKey(key))) + if (!Kept(key, null)) + Add(key, null); + return changes; + + bool Kept(string key, string? value) + { + // Past endconfig the simulator never gets to it; bare, it takes the next + // word of the file for its value. + if (_config.IsPastEnd(key) || _config.GetRaw(key) is { Length: 0 }) + return false; + if (string.Equals(key, "uartfeature", StringComparison.OrdinalIgnoreCase)) + return Uart.FeaturesStand(_config.GetRaw(key), value); + if (SettingsSerializer.FixedLengthKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) + return _config.GetRaw(key) == value; + string? synced = _synced.GetValueOrDefault(key); + if (shown.TryGetValue(key, out var onScreen) && onScreen.Shown == value && onScreen.Held == synced) + return true; + return _config.Has(key) && synced == value; + } + + void Add(string key, string? value) + { + // 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) + changes.Add(new IniChange(key, before, value)); + } + } + + /// + /// The keys puts otherwise than the settings hold them, + /// with both values, in the file's terms. + /// + public IReadOnlyDictionary ShownUnlike(SimulatorSettings shown) + { + var held = Render(Settings); + var now = Render(shown); + var unlike = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string key in held.Keys.Union(now.Keys, StringComparer.OrdinalIgnoreCase)) + { + string? before = held.GetValueOrDefault(key), after = now.GetValueOrDefault(key); + if (before != after) + unlike[key] = new ShownValue(after, before); + } + return unlike; + } + + /// + /// A copy of with back at + /// what the simulator goes by when eu07.ini does not set them. + /// + public SimulatorSettings WithDefaults(SimulatorSettings settings, IReadOnlyCollection keys) + { + var defaults = new SimulatorSettings(); + _serializer.Read(defaults, new ConfigFile()); + return WithKeysOf(settings, defaults, keys); + } + + /// A copy of with as has them. + public SimulatorSettings WithKeysOf(SimulatorSettings settings, SimulatorSettings source, IReadOnlyCollection keys) + { + var taken = Render(source); + + var config = new ConfigFile(); + foreach (var (key, value) in Render(settings)) + if (!keys.Contains(key, StringComparer.OrdinalIgnoreCase)) + config.Set(key, value); + foreach (string key in keys) + if (taken.TryGetValue(key, out string? value)) + config.Set(key, value); + + var copy = settings.Copy(); + _serializer.Read(copy, config); + return copy; + } + public void TouchConfigAge() { string path = string.IsNullOrEmpty(_savePath) ? _paths.UserConfigPath() : _savePath; diff --git a/StarterNG/Classes/KeyMap.cs b/StarterNG/Classes/KeyMap.cs index 741c606..f10f445 100644 --- a/StarterNG/Classes/KeyMap.cs +++ b/StarterNG/Classes/KeyMap.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Avalonia.Input; namespace StarterNG.Classes; @@ -18,8 +19,46 @@ public sealed class KeyCap } } +/// What the simulator does with a key combination on its own. +/// What that is, as a translation key. +/// +/// It gets there ahead of the key file, so a binding here would never fire. +/// Otherwise it is what is left to the key when no binding takes it. +/// +public readonly record struct SimulatorKeyUse(string TextKey, bool Blocks); + public static class KeyMap { + public const string NumpadPrefix = "num_"; + + /// + /// Read from the simulator's code: ui_layer::on_key and driver_ui::on_key are asked first and only + /// without a modifier, driver_mode::on_key takes Shift+F12 itself, then comes the key + /// file, and driver_mode::OnKeyDown gets what that leaves. + /// + public static SimulatorKeyUse? SimulatorUse(string token, bool shift, bool ctrl) + { + bool plain = !shift && !ctrl; + switch (token.ToLowerInvariant()) + { + case "tab" when plain: return new SimulatorKeyUse("KbUseMap", true); + case "f1" when plain: return new SimulatorKeyUse("KbUseConsist", true); + case "f2" when plain: return new SimulatorKeyUse("KbUseTimetable", true); + case "f3" when plain: return new SimulatorKeyUse("KbUseScenario", true); + case "f9" when plain: return new SimulatorKeyUse("KbUseLog", true); + case "f10" when plain: return new SimulatorKeyUse("KbUseQuit", true); + case "f12" when shift: return new SimulatorKeyUse("KbUseDebugPanel", true); + + case "f4": return new SimulatorKeyUse("KbUseCab", false); + case "f5": return new SimulatorKeyUse("KbUseVehicle", false); + case "f6": return new SimulatorKeyUse("KbUseDebug", false); + case "f7" when plain || ctrl: return new SimulatorKeyUse("KbUseDebug", false); + case "f11" when plain: return new SimulatorKeyUse("KbUseEditor", false); + case [>= '0' and <= '9'] when shift: return new SimulatorKeyUse("KbUseSceneryEvent", false); + case [>= '0' and <= '9'] when ctrl: return new SimulatorKeyUse("KbUseCamera", false); + default: return null; + } + } public static string? FromInput(Key key, PhysicalKey physical) { @@ -75,6 +114,16 @@ public static class KeyMap Key.OemSemicolon => ";", Key.OemQuotes => "'", Key.OemBackslash or Key.OemPipe => "\\", + Key.OemOpenBrackets => "[", + Key.OemCloseBrackets => "]", + Key.Enter => "enter", + Key.Tab => "tab", + Key.PageUp => "page_up", + Key.PageDown => "page_down", + Key.Left => "left", + Key.Right => "right", + Key.Up => "up", + Key.Down => "down", Key.F1 => "f1", Key.F2 => "f2", Key.F3 => "f3", Key.F4 => "f4", Key.F5 => "f5", Key.F6 => "f6", Key.F7 => "f7", Key.F8 => "f8", Key.F9 => "f9", Key.F10 => "f10", Key.F11 => "f11", Key.F12 => "f12", @@ -88,15 +137,29 @@ public static class KeyMap Key.LeftAlt or Key.RightAlt or Key.LWin or Key.RWin; + /// A key combination as it reads on screen; a dash for no key, whatever the modifiers. + public static string ComboText(string? token, bool shift, bool ctrl) + { + if (IsNoKey(token)) + return "—"; + var parts = new List(); + if (ctrl) parts.Add("Ctrl"); + if (shift) parts.Add("Shift"); + parts.Add(DisplayName(token)); + return string.Join(" + ", parts); + } + + private static bool IsNoKey([NotNullWhen(false)] string? token) => + string.IsNullOrEmpty(token) || token.Equals("none", StringComparison.OrdinalIgnoreCase); + public static string DisplayName(string? token) { - if (string.IsNullOrEmpty(token) || - token.Equals("none", StringComparison.OrdinalIgnoreCase)) + if (IsNoKey(token)) return "—"; - if (token.StartsWith("num_", StringComparison.OrdinalIgnoreCase)) + if (token.StartsWith(NumpadPrefix, StringComparison.OrdinalIgnoreCase)) { - string rest = token.Substring(4); + string rest = token.Substring(NumpadPrefix.Length); return rest switch { "enter" => "Num Enter", @@ -116,8 +179,9 @@ public static class KeyMap "end" => "End", "insert" => "Insert", "delete" => "Delete", - "pageup" => "Page Up", - "pagedown" => "Page Down", + "page_up" => "Page Up", + "page_down" => "Page Down", + "esc" => "Esc", "backspace" => "Backspace", "pause" => "Pause", "\\" => "\\", @@ -131,10 +195,10 @@ public static class KeyMap new[] { - new KeyCap("Esc", null, 1.4), - new KeyCap("F1"), new KeyCap("F2"), new KeyCap("F3"), new KeyCap("F4"), - new KeyCap("F5"), new KeyCap("F6"), new KeyCap("F7"), new KeyCap("F8"), - new KeyCap("F9"), new KeyCap("F10"), new KeyCap("F11"), new KeyCap("F12"), + new KeyCap("Esc", "esc", 1.4), + new KeyCap("F1", "f1"), new KeyCap("F2", "f2"), new KeyCap("F3", "f3"), new KeyCap("F4", "f4"), + new KeyCap("F5", "f5"), new KeyCap("F6", "f6"), new KeyCap("F7", "f7"), new KeyCap("F8", "f8"), + new KeyCap("F9", "f9"), new KeyCap("F10", "f10"), new KeyCap("F11", "f11"), new KeyCap("F12", "f12"), }, new[] { @@ -147,11 +211,11 @@ public static class KeyMap }, new[] { - new KeyCap("Tab", null, 1.5), + new KeyCap("Tab", "tab", 1.5), new KeyCap("Q", "q"), new KeyCap("W", "w"), new KeyCap("E", "e"), new KeyCap("R", "r"), new KeyCap("T", "t"), new KeyCap("Y", "y"), new KeyCap("U", "u"), new KeyCap("I", "i"), new KeyCap("O", "o"), new KeyCap("P", "p"), - new KeyCap("[", null), new KeyCap("]", null), + new KeyCap("[", "["), new KeyCap("]", "]"), new KeyCap("\\", "\\", 1.5), }, new[] @@ -161,7 +225,7 @@ public static class KeyMap new KeyCap("G", "g"), new KeyCap("H", "h"), new KeyCap("J", "j"), new KeyCap("K", "k"), new KeyCap("L", "l"), new KeyCap(";", ";"), new KeyCap("'", "'"), - new KeyCap("Enter", null, 2.2), + new KeyCap("Enter", "enter", 2.2), }, new[] { @@ -215,11 +279,19 @@ public static class KeyMap new[] { - new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp"), + new KeyCap("Ins", "insert"), new KeyCap("Home", "home"), new KeyCap("PgUp", "page_up"), }, new[] { - new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn"), + new KeyCap("Del", "delete"), new KeyCap("End", "end"), new KeyCap("PgDn", "page_down"), + }, + new[] + { + new KeyCap("", null), new KeyCap("↑", "up"), new KeyCap("", null), + }, + new[] + { + new KeyCap("←", "left"), new KeyCap("↓", "down"), new KeyCap("→", "right"), }, }; } diff --git a/StarterNG/Classes/KeyboardConfig.cs b/StarterNG/Classes/KeyboardConfig.cs index 99acdaa..cd9a726 100644 --- a/StarterNG/Classes/KeyboardConfig.cs +++ b/StarterNG/Classes/KeyboardConfig.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using StarterNG.Infrastructure.Adapters; @@ -23,6 +24,20 @@ public sealed class KeyBinding public bool IsAssigned => !string.IsNullOrEmpty(Key) && !Key.Equals("none", StringComparison.OrdinalIgnoreCase); + + /// The combination in a form to compare, however the key is spelled. + public string ComboKey => $"{(Ctrl ? 1 : 0)}|{(Shift ? 1 : 0)}|{Key.ToLowerInvariant()}"; + + public void Clear() + { + Shift = Ctrl = false; + Key = "none"; + } + + public bool IsOn(string token, bool shift, bool ctrl) => + IsAssigned && + string.Equals(Key, token, StringComparison.OrdinalIgnoreCase) && + Shift == shift && Ctrl == ctrl; } public sealed class KeyboardConfig @@ -119,19 +134,66 @@ public sealed class KeyboardConfig } /// - /// The description to show: the bundled one with Polish letters when the file's - /// is the same text without them, the file's own when someone wrote their own. + /// The description to show: the file's own when someone wrote their own, else + /// the one for the starter's language, else the + /// bundled one with Polish letters. /// - public string DescriptionOf(KeyBinding binding) + public string DescriptionOf(KeyBinding binding, Func? translated = null) { - if (_polishDescriptions.TryGetValue(binding.Command, out string? polish) && - (string.IsNullOrEmpty(binding.Description) || - string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase))) - return polish; + bool stock = string.IsNullOrEmpty(binding.Description); + if (_polishDescriptions.TryGetValue(binding.Command, out string? polish)) + stock |= string.Equals(StripPolish(polish), binding.Description.Trim(), StringComparison.OrdinalIgnoreCase); + if (!stock) + return binding.Description; - return binding.Description; + if (translated?.Invoke(binding.Command) is { Length: > 0 } text) + return text; + + return polish ?? binding.Description; } + public KeyBinding? BindingAt(string token, bool shift, bool ctrl) => + Bindings.FirstOrDefault(b => b.IsOn(token, shift, ctrl)); + + /// + /// Puts on a key combination, or nothing for null, and + /// frees it from the rest: the simulator maps a combination to one command - in + /// driving and outside the cab alike, the editor has its own fixed keys - so of + /// two, one would silently never fire. + /// + public void Put(KeyBinding? binding, string token, bool shift, bool ctrl) + { + foreach (var b in Bindings) + if (!ReferenceEquals(b, binding) && b.IsOn(token, shift, ctrl)) + b.Clear(); + + if (binding is null) + return; + + binding.Key = token; + binding.Shift = shift; + binding.Ctrl = ctrl; + } + + /// The of each combination more than one binding is on. + public HashSet ConflictingCombos() + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var dup = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var b in Bindings) + { + if (!b.IsAssigned) + continue; + string k = b.ComboKey; + if (!seen.Add(k)) + dup.Add(k); + } + return dup; + } + + public int BindingsOnNumpad() => + Bindings.Count(b => b.IsAssigned && b.Key.StartsWith(KeyMap.NumpadPrefix, StringComparison.OrdinalIgnoreCase)); + public static string StripPolish(string text) { var sb = new StringBuilder(text.Length); diff --git a/StarterNG/Domain/Settings/GpuOffload.cs b/StarterNG/Domain/Settings/GpuOffload.cs new file mode 100644 index 0000000..b27dd6e --- /dev/null +++ b/StarterNG/Domain/Settings/GpuOffload.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StarterNG.Domain.Settings; + +/// Which graphics card the simulator is started on, where there are two. +public enum GpuPreference +{ + /// The card that is not driving the boot display - the discrete one on a laptop. + Discrete, + /// Whatever the system gives a program, as a rule the integrated card. + System +} + +/// What there is to say about the cards found and the choice made. +public enum GpuNote +{ + /// No card could be read. + Unread, + /// One card leaves nothing to choose. + OneCard, + /// The system decides, by choice. + LeftToSystem, + /// The player chose, by what the starter was started with. + PlayerChose, + /// The simulator would be moved to . + Moved, + /// There is no card to move the simulator to. + NoTarget +} + +/// A graphics card as Linux lists it under /sys/class/drm. +/// PCI address, as 0000:01:00.0. +/// Vendor and device, as 10DE:2D19. +/// The kernel driver: nvidia, amdgpu, i915... +/// The card the firmware started the display on. +/// The card has a laptop's built-in screen among its outputs (eDP, LVDS, DSI). +public sealed record GpuDevice(string Slot, string PciId, string Driver, bool BootVga, bool InternalPanel = false) +{ + public string Vendor => PciId.Split(':')[0].ToUpperInvariant() switch + { + "10DE" => "NVIDIA", + "1002" => "AMD", + "8086" => "Intel", + _ => PciId + }; + + public override string ToString() => $"{Vendor} {PciId} ({Driver})"; +} + +/// +/// The environment that moves a native Linux simulator onto the discrete card, +/// as switcheroo-control would set it. A Windows build asks for it itself +/// (NvOptimusEnablement in windows.cpp); on Linux the choice is made by +/// variables read when libGL loads, so the one starting the program sets them. +/// +public static class GpuOffload +{ + // Any of these already set means the player chose - prime-run %command% in + // Steam's launch options, say - and the choice is theirs. + private static readonly string[] SelectionVariables = + { + "__NV_PRIME_RENDER_OFFLOAD", "__GLX_VENDOR_LIBRARY_NAME", "__VK_LAYER_NV_optimus", + "DRI_PRIME", "MESA_VK_DEVICE_SELECT", "VK_LOADER_DRIVERS_SELECT" + }; + + // Cards Mesa drives, which DRI_PRIME can pick. On one the proprietary NVIDIA + // driver owns, DRI_PRIME leaves Mesa without a card and on llvmpipe. + private static readonly HashSet MesaDrivers = new(StringComparer.Ordinal) + { + "amdgpu", "radeon", "i915", "xe", "nouveau" + }; + + /// The card the simulator would be moved to, or null when it is left alone. + public static GpuDevice? Target(IReadOnlyList gpus, GpuPreference preference, + Func inherited) + { + if (preference == GpuPreference.System || gpus.Count < 2) + return null; + if (PlayerChoices(inherited).Count > 0) + return null; + + // Only a laptop's integrated card hands its work to the other one. A desktop + // runs on the card its monitor is plugged into - moving off it could as well + // land on the integrated one - and an NVIDIA card driving the screen, through + // a MUX, is already the discrete one. + var boot = gpus.FirstOrDefault(g => g.BootVga); + if (boot is null || !boot.InternalPanel || boot.Vendor == "NVIDIA") + return null; + return gpus.FirstOrDefault(g => !g.BootVga && Supported(g)); + } + + /// The variables that say the player chose a card, of those set. + public static IReadOnlyList PlayerChoices(Func inherited) => + SelectionVariables.Where(name => !string.IsNullOrEmpty(inherited(name))).ToArray(); + + public static GpuNote NoteFor(IReadOnlyList gpus, GpuPreference preference, + Func inherited) => + gpus.Count switch + { + 0 => GpuNote.Unread, + 1 => GpuNote.OneCard, + _ when preference == GpuPreference.System => GpuNote.LeftToSystem, + _ when PlayerChoices(inherited).Count > 0 => GpuNote.PlayerChose, + _ => Target(gpus, preference, inherited) is not null ? GpuNote.Moved : GpuNote.NoTarget + }; + + private static bool Supported(GpuDevice gpu) => gpu.Driver == "nvidia" || MesaDrivers.Contains(gpu.Driver); + + /// The variables that put a program on . + public static IReadOnlyDictionary EnvironmentFor(GpuDevice target) => + target.Driver == "nvidia" + ? new Dictionary + { + ["__NV_PRIME_RENDER_OFFLOAD"] = "1", + ["__GLX_VENDOR_LIBRARY_NAME"] = "nvidia", + ["__VK_LAYER_NV_optimus"] = "NVIDIA_only", + ["VK_LOADER_DRIVERS_SELECT"] = "*nvidia*" + } + : new Dictionary + { + ["DRI_PRIME"] = "pci-" + target.Slot.Replace(':', '_').Replace('.', '_') + }; +} diff --git a/StarterNG/Domain/Settings/GraphicsPreset.cs b/StarterNG/Domain/Settings/GraphicsPreset.cs new file mode 100644 index 0000000..78abedd --- /dev/null +++ b/StarterNG/Domain/Settings/GraphicsPreset.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace StarterNG.Domain.Settings; + +/// +/// What only the two ends of the scale touch: the effects left to taste at every other +/// level and how often the screens refresh. The lowest level gives them up, the highest +/// takes the most there is. +/// +/// python.updatetime, one of the steps of its list. +public sealed record GraphicsExtras( + bool MotionBlur, + bool ChromaticAberration, + bool Smoke, + int PythonUpdateMs) +{ + /// The eu07.ini keys these values live in. + public static readonly IReadOnlyList Keys = new[] + { + "gfx.postfx.motionblur.enabled", "gfx.postfx.chromaticaberration.enabled", + "gfx.smoke", "python.updatetime" + }; + + /// The simulator's own, for leaving a level chosen before this run: what was there earlier is gone. + public static readonly GraphicsExtras None = new(true, true, true, 200); + + public static GraphicsExtras Of(SimulatorSettings s) => + new(s.MotionBlur, s.ChromaticAberration, s.Smoke, s.PythonScreenUpdateRate); + + public void ApplyTo(SimulatorSettings s) + { + s.MotionBlur = MotionBlur; + s.ChromaticAberration = ChromaticAberration; + s.Smoke = Smoke; + s.PythonScreenUpdateRate = PythonUpdateMs; + } +} + +/// +/// What only Ultra sets, switches and limits with no scale to climb. Not every best is the +/// top of a scale: shadows are longest with the limit at its bottom, the regulator and the +/// release of textures would only take away, off. +/// Left to the player, picture unchanged: separate windows for the screens +/// (python.displaywindows, python.vsync), texture streaming, the frame pacing. +/// +/// gfx.shadow.angle.min: how low the sun counts for shadows, so how long they +/// get: up to 5× the height of what casts them at 0.2, no longer than it at 1 (opengl33renderer.cpp). +/// targetfps: above 0 the simulator moves the distance factor by itself. +/// python.fpslimit: read only by the screens' separate windows +/// (pythonscreenviewer.cpp), it does nothing without them. +/// gfx.textures.releasedistance: above 0 textures of far vehicles leave the card. +/// compresstex: on saves card memory at a cost to the picture. +/// starter.ignoreirrelevant: on leaves decorative consists out of the scenario. +public sealed record GraphicsTop( + double ShadowAngleLimit, + double TargetFps, + double ScreenWindowFps, + double TextureRelease, + bool Screens, + bool ScreenMipmaps, + bool CompressTextures, + bool SkipDecor, + bool Sky, + bool Cab, + bool AirCouplers, + bool SmoothTraction) +{ + public const string TargetFpsKey = "targetfps"; + public const string ScreenWindowFpsKey = "python.fpslimit"; + public const string TextureReleaseKey = "gfx.textures.releasedistance"; + public const string SkyKey = "skyenabled"; + public const string CabKey = "rendercab"; + public const string AirCouplersKey = "newaircouplers"; + public const string SmoothTractionKey = "smoothtraction"; + + /// Those of its keys shown as rows of . + public static readonly IReadOnlyList RowKeys = new[] + { + TargetFpsKey, ScreenWindowFpsKey, TextureReleaseKey, SkyKey, CabKey, AirCouplersKey, SmoothTractionKey + }; + + /// The eu07.ini keys these values live in. + public static readonly IReadOnlyList Keys = RowKeys.Concat(new[] + { + "gfx.shadow.angle.min", "python.enabled", "python.mipmaps", "compresstex", "starter.ignoreirrelevant" + }).ToList(); + + /// What they are with nothing set, as . + public static readonly GraphicsTop None = + new(0.2, 0, 100, 0, true, true, true, false, true, true, true, true); + + public static GraphicsTop Of(SimulatorSettings s) => + new(s.ShadowAngleLimit, GraphicsPreset.NumberOf(s, TargetFpsKey), + GraphicsPreset.NumberOf(s, ScreenWindowFpsKey), GraphicsPreset.NumberOf(s, TextureReleaseKey), + s.PythonScreens, s.ScreenMipmaps, s.CompressTextures, s.IgnoreIrrelevantTrains, + GraphicsPreset.ToggleOf(s, SkyKey), GraphicsPreset.ToggleOf(s, CabKey), + GraphicsPreset.ToggleOf(s, AirCouplersKey), GraphicsPreset.ToggleOf(s, SmoothTractionKey)); + + public void ApplyTo(SimulatorSettings s) + { + void Number(string key, double value) => s.Extra[key] = value.ToString(CultureInfo.InvariantCulture); + void Toggle(string key, bool value) => s.Extra[key] = SimulatorKey.FormatToggle(value); + + s.ShadowAngleLimit = ShadowAngleLimit; + Number(TargetFpsKey, TargetFps); + Number(ScreenWindowFpsKey, ScreenWindowFps); + Number(TextureReleaseKey, TextureRelease); + s.PythonScreens = Screens; + s.ScreenMipmaps = ScreenMipmaps; + s.CompressTextures = CompressTextures; + s.IgnoreIrrelevantTrains = SkipDecor; + Toggle(SkyKey, Sky); + Toggle(CabKey, Cab); + Toggle(AirCouplersKey, AirCouplers); + Toggle(SmoothTractionKey, SmoothTraction); + } +} + +/// +/// A level of detail set in one go: what weighs on the graphics card. Every scale climbs in +/// even steps from to , as far as it has steps for six +/// levels. The screen (resolution, field of view), the frame pacing and the effects that are a +/// matter of taste - motion blur, chromatic aberration - stay as they are, except at the +/// two ends of the scale, which have . +/// +/// Its name's translation key. +/// gfx.reflections.framerate, in updates a second. +/// basedrawrange, in metres. Below 2000 it changes nothing on screen: the renderer +/// keeps at least that for signals and lights (opengl33renderer.cpp). +/// heightbase: the only thing that draws less than those 2 km, and draws +/// shapes, wires and sections nearer. A regulating targetfps moves it from there. +/// starter.bufferscale, in percent. Above the screen over High: on a 4K one +/// Ultra is a picture of 8K, multisampled on top. +/// gfx.drawrange.factor.max: how far the regulator may stretch the draw range. +/// gfx.shadows.cab.range, in metres, read only by the legacy renderer. +public sealed record GraphicsPreset( + string NameKey, + int TextureSize, + int CabTextureSize, + double Anisotropy, + int Multisampling, + int DynamicLights, + int SmokeFidelity, + bool Shadows, + int ShadowMap, + int ShadowRange, + int ShadowRank, + bool EnvMap, + int ReflectionsFidelity, + double ReflectionsRate, + bool ExtraEffects, + int SplineFidelity, + double DrawRange, + double DistanceFactor, + int BufferScale, + double SleeperDistance, + double DrawRangeFactor, + double ReflectedInstances, + double ReflectedVehicles, + int CabShadowsRange, + GraphicsExtras? Extras = null, + GraphicsTop? Top = null) +{ + public const string DrawRangeKey = "basedrawrange"; + public const string DistanceFactorKey = "heightbase"; + public const string SleeperKey = "sleeperdistance"; + public const string ReflectedInstancesKey = "gfx.reflections.range_instances"; + public const string ReflectedVehiclesKey = "gfx.reflections.range_vehicles"; + + /// Those of its keys shown as rows of . + public static readonly IReadOnlyList RowKeys = new[] + { + DrawRangeKey, DistanceFactorKey, SleeperKey, ReflectedInstancesKey, ReflectedVehiclesKey + }; + + /// The eu07.ini keys a level sets. + public static readonly IReadOnlyList Keys = new[] + { + "maxtexturesize", "maxcabtexturesize", "anisotropicfiltering", "multisampling", "dynamiclights", + "gfx.smoke.fidelity", "shadows", "gfx.shadowmap.enabled", "shadowtune", "gfx.shadow.rank.cutoff", + "gfx.envmap.enabled", "gfx.reflections.fidelity", "gfx.reflections.framerate", "gfx.extraeffects", + "splinefidelity", DrawRangeKey, DistanceFactorKey, "starter.bufferscale", SleeperKey, + "gfx.drawrange.factor.max", ReflectedInstancesKey, ReflectedVehiclesKey, "gfx.shadows.cab.range" + }; + + /// The last resort: no shadows, no reflections, no effects, no smoke, a softer picture. + public static readonly GraphicsPreset Minimal = new( + "PresetMinimal", TextureSize: 512, CabTextureSize: 512, Anisotropy: 1, Multisampling: 0, + DynamicLights: 1, SmokeFidelity: 1, Shadows: false, ShadowMap: 512, ShadowRange: 75, ShadowRank: 1, + EnvMap: false, ReflectionsFidelity: 0, ReflectionsRate: 1 / 300.0, ExtraEffects: false, + SplineFidelity: 1, DrawRange: 2000, DistanceFactor: 0.6, BufferScale: 70, + SleeperDistance: 100, DrawRangeFactor: 1, ReflectedInstances: 250, ReflectedVehicles: 50, CabShadowsRange: 0, + Extras: new GraphicsExtras(MotionBlur: false, ChromaticAberration: false, Smoke: false, PythonUpdateMs: 800)); + + public static readonly GraphicsPreset Low = new( + "PresetLow", TextureSize: 1024, CabTextureSize: 1024, Anisotropy: 2, Multisampling: 0, + DynamicLights: 3, SmokeFidelity: 1, Shadows: true, ShadowMap: 1024, ShadowRange: 125, ShadowRank: 2, + EnvMap: true, ReflectionsFidelity: 0, ReflectionsRate: 1 / 60.0, ExtraEffects: true, + SplineFidelity: 1, DrawRange: 2000, DistanceFactor: 0.7, BufferScale: 80, + SleeperDistance: 150, DrawRangeFactor: 1.5, ReflectedInstances: 400, ReflectedVehicles: 100, CabShadowsRange: 0); + + public static readonly GraphicsPreset Medium = new( + "PresetMedium", TextureSize: 2048, CabTextureSize: 2048, Anisotropy: 4, Multisampling: 1, + DynamicLights: 5, SmokeFidelity: 2, Shadows: true, ShadowMap: 1024, ShadowRange: 175, ShadowRank: 2, + EnvMap: true, ReflectionsFidelity: 1, ReflectionsRate: 1 / 30.0, ExtraEffects: true, + SplineFidelity: 2, DrawRange: 2250, DistanceFactor: 0.85, BufferScale: 90, + SleeperDistance: 200, DrawRangeFactor: 2, ReflectedInstances: 550, ReflectedVehicles: 150, CabShadowsRange: 20); + + /// + /// Where a player with no settings of their own starts. The simulator's own values (Globals.h), + /// but for smoke, track curves, reflections and cab shadows: theirs are the bottom of a scale + /// with levels to go under this one. + /// + public static readonly GraphicsPreset High = new( + "PresetHigh", TextureSize: 4096, CabTextureSize: 4096, Anisotropy: 8, Multisampling: 2, + DynamicLights: 7, SmokeFidelity: 2, Shadows: true, ShadowMap: 2048, ShadowRange: 250, ShadowRank: 3, + EnvMap: true, ReflectionsFidelity: 1, ReflectionsRate: 0.1, ExtraEffects: true, + SplineFidelity: 2, DrawRange: 2500, DistanceFactor: 1, BufferScale: 100, + SleeperDistance: 250, DrawRangeFactor: 3, ReflectedInstances: 750, ReflectedVehicles: 250, CabShadowsRange: 40); + + /// For a strong card. A map of 4096 doubles the cascades, so the range here reaches 800 m. + public static readonly GraphicsPreset VeryHigh = new( + "PresetVeryHigh", TextureSize: 8192, CabTextureSize: 8192, Anisotropy: 16, Multisampling: 3, + DynamicLights: 7, SmokeFidelity: 3, Shadows: true, ShadowMap: 4096, ShadowRange: 400, ShadowRank: 3, + EnvMap: true, ReflectionsFidelity: 2, ReflectionsRate: 2, ExtraEffects: true, + SplineFidelity: 3, DrawRange: 5000, DistanceFactor: 2, BufferScale: 150, + SleeperDistance: 2000, DrawRangeFactor: 3, ReflectedInstances: 1500, ReflectedVehicles: 1000, CabShadowsRange: 70); + + /// + /// Every value at the top of what the screen offers. Not the best picture throughout: a map + /// of 8192 stretches the cascades fourfold, so shadows reach 2400 m but are coarser up close + /// than at . + /// + public static readonly GraphicsPreset Ultra = new( + "PresetUltra", TextureSize: 8192, CabTextureSize: 8192, Anisotropy: 16, Multisampling: 3, + DynamicLights: 7, SmokeFidelity: 4, Shadows: true, ShadowMap: 8192, ShadowRange: 600, ShadowRank: 3, + EnvMap: true, ReflectionsFidelity: 2, ReflectionsRate: 60, ExtraEffects: true, + SplineFidelity: 4, DrawRange: 10000, DistanceFactor: 3, BufferScale: 200, + SleeperDistance: 5000, DrawRangeFactor: 3, ReflectedInstances: 3000, ReflectedVehicles: 2000, CabShadowsRange: 100, + Extras: new GraphicsExtras(MotionBlur: true, ChromaticAberration: true, Smoke: true, PythonUpdateMs: 200), + Top: new GraphicsTop(ShadowAngleLimit: 0.2, TargetFps: 0, ScreenWindowFps: 200, TextureRelease: 0, + Screens: true, ScreenMipmaps: true, CompressTextures: false, SkipDecor: false, + Sky: true, Cab: true, AirCouplers: true, SmoothTraction: true)); + + public static readonly IReadOnlyList All = new[] { Minimal, Low, Medium, High, VeryHigh, Ultra }; + + public void ApplyTo(SimulatorSettings s) + { + s.MaxTextureSize = TextureSize; + s.MaxCabTextureSize = CabTextureSize; + s.TextureFiltering = Anisotropy; + s.Multisampling = Multisampling; + s.DynamicLights = DynamicLights; + s.SmokeFidelity = SmokeFidelity; + s.RenderShadows = Shadows; + s.ShadowMapResolution = ShadowMap; + s.ShadowProjectionRange = ShadowRange; + s.ShadowRankCutoff = ShadowRank; + s.EnvMap = EnvMap; + s.ReflectionsFidelity = ReflectionsFidelity; + s.ReflectionsFramerate = ReflectionsRate; + s.ExtraEffects = ExtraEffects; + s.SplineFidelity = SplineFidelity; + s.Extra[DrawRangeKey] = DrawRange.ToString(CultureInfo.InvariantCulture); + s.Extra[DistanceFactorKey] = DistanceFactor.ToString(CultureInfo.InvariantCulture); + s.BufferScalePercent = BufferScale; + s.Extra[SleeperKey] = SleeperDistance.ToString(CultureInfo.InvariantCulture); + s.DrawRangeFactor = DrawRangeFactor; + s.Extra[ReflectedInstancesKey] = ReflectedInstances.ToString(CultureInfo.InvariantCulture); + s.Extra[ReflectedVehiclesKey] = ReflectedVehicles.ToString(CultureInfo.InvariantCulture); + s.CabShadowsRange = CabShadowsRange; + Extras?.ApplyTo(s); + Top?.ApplyTo(s); + } + + /// Whether is at this level throughout. + public bool Matches(SimulatorSettings s) => + s.MaxTextureSize == TextureSize && s.MaxCabTextureSize == CabTextureSize && + Near(s.TextureFiltering, Anisotropy) && s.Multisampling == Multisampling && + s.DynamicLights == DynamicLights && s.SmokeFidelity == SmokeFidelity && + s.RenderShadows == Shadows && s.ShadowMapResolution == ShadowMap && + s.ShadowProjectionRange == ShadowRange && s.ShadowRankCutoff == ShadowRank && + s.EnvMap == EnvMap && s.ReflectionsFidelity == ReflectionsFidelity && + Near(s.ReflectionsFramerate, ReflectionsRate) && s.ExtraEffects == ExtraEffects && + s.SplineFidelity == SplineFidelity && Near(NumberOf(s, DrawRangeKey), DrawRange) && + Near(NumberOf(s, DistanceFactorKey), DistanceFactor) && + s.BufferScalePercent == BufferScale && + Near(NumberOf(s, SleeperKey), SleeperDistance) && Near(s.DrawRangeFactor, DrawRangeFactor) && + Near(NumberOf(s, ReflectedInstancesKey), ReflectedInstances) && + Near(NumberOf(s, ReflectedVehiclesKey), ReflectedVehicles) && + s.CabShadowsRange == CabShadowsRange && + (Extras is null || GraphicsExtras.Of(s) == Extras) && + (Top is null || GraphicsTop.Of(s) == Top); + + /// The level is at throughout, if any. + public static GraphicsPreset? Matching(SimulatorSettings s) => All.FirstOrDefault(preset => preset.Matches(s)); + + internal static double NumberOf(SimulatorSettings s, string name) + { + var key = SimulatorKeys.Named(name); + return key.ParseNumber(key.RawIn(s)); + } + + internal static bool ToggleOf(SimulatorSettings s, string name) => + SimulatorKey.ParseToggle(SimulatorKeys.Named(name).RawIn(s)); + + // As the file rounds it: 1/300 comes back as 0.003333. + private static bool Near(double value, double target) => + Math.Abs(value - target) <= Math.Abs(target) * 2e-4 + 1e-9; +} + +/// +/// A part of the picture with a level of its own, as its section on the screen has it. The rest +/// of a level - the buffer, the frame pacing, the sky and the cab - goes only with the whole. +/// +/// The eu07.ini keys it sets. +public sealed record GraphicsGroup(IReadOnlyList Keys) +{ + public static readonly GraphicsGroup DrawRange = new(new[] + { + GraphicsPreset.DrawRangeKey, GraphicsPreset.DistanceFactorKey, "splinefidelity", + GraphicsPreset.SleeperKey, "gfx.drawrange.factor.max" + }); + + public static readonly GraphicsGroup Textures = new(new[] + { + "maxtexturesize", "maxcabtexturesize", "anisotropicfiltering", "multisampling", + "compresstex", GraphicsTop.TextureReleaseKey + }); + + public static readonly GraphicsGroup Effects = new(new[] + { + "dynamiclights", "gfx.smoke.fidelity", "gfx.extraeffects", "gfx.postfx.motionblur.enabled", + "gfx.postfx.chromaticaberration.enabled", "gfx.smoke" + }); + + public static readonly GraphicsGroup Shadows = new(new[] + { + "shadows", "gfx.shadowmap.enabled", "shadowtune", "gfx.shadow.rank.cutoff", + "gfx.shadow.angle.min", "gfx.shadows.cab.range" + }); + + public static readonly GraphicsGroup Reflections = new(new[] + { + "gfx.envmap.enabled", "gfx.reflections.fidelity", "gfx.reflections.framerate", + GraphicsPreset.ReflectedInstancesKey, GraphicsPreset.ReflectedVehiclesKey + }); + + public static readonly IReadOnlyList All = new[] { DrawRange, Textures, Effects, Shadows, Reflections }; +} + +/// What applying a level touched. +/// The keys of rows to show its values in, in that order. +/// The eu07.ini keys it chose as a whole. +public sealed record PresetApplied(IReadOnlyList RowKeys, IReadOnlyList ChosenKeys); + +/// +/// Remembers what was there before an end of the scale, which comes back with any level +/// between. Leaving one chosen before this run, what was there earlier is gone, so the +/// simulator's own values take its place. +/// +public sealed class GraphicsPresetMemory +{ + private GraphicsExtras? _beforeExtras; + + private GraphicsTop? _beforeTop; + + /// Puts in , with what an end of the scale left behind. + public PresetApplied Apply(GraphicsPreset preset, SimulatorSettings settings) + { + var current = GraphicsPreset.Matching(settings); + var coming = preset.Extras is not null ? null + : _beforeExtras ?? (current?.Extras is not null ? GraphicsExtras.None : null); + _beforeExtras = preset.Extras is not null ? _beforeExtras ?? GraphicsExtras.Of(settings) : null; + var comingTop = preset.Top is not null ? null + : _beforeTop ?? (current?.Top is not null ? GraphicsTop.None : null); + _beforeTop = preset.Top is not null ? _beforeTop ?? GraphicsTop.Of(settings) : null; + coming?.ApplyTo(settings); + comingTop?.ApplyTo(settings); + preset.ApplyTo(settings); + + var rows = new List(GraphicsPreset.RowKeys); + var chosen = new List(GraphicsPreset.Keys); + if (preset.Extras is not null || coming is not null) + chosen.AddRange(GraphicsExtras.Keys); + if (preset.Top is not null || comingTop is not null) + { + chosen.AddRange(GraphicsTop.Keys); + rows.AddRange(GraphicsTop.RowKeys); + } + return new PresetApplied(rows, chosen); + } +} diff --git a/StarterNG/Domain/Settings/ResolutionChoices.cs b/StarterNG/Domain/Settings/ResolutionChoices.cs new file mode 100644 index 0000000..35407da --- /dev/null +++ b/StarterNG/Domain/Settings/ResolutionChoices.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; + +namespace StarterNG.Domain.Settings; + +/// A size in pixels, as width and height in eu07.ini take it. +public readonly record struct ScreenSize(int Width, int Height) +{ + public override string ToString() => $"{Width}x{Height}"; +} + +/// A size to offer for the simulator's window. +/// A monitor in the computer has exactly this size. +public readonly record struct ResolutionChoice(ScreenSize Size, bool IsMonitor); + +/// +/// What the resolution list offers: the monitors in the computer first, then the +/// usual sizes that fit on one of them, largest first. A full-screen simulator gets +/// the closest mode its monitor has, so a size no monitor can show is of no use. +/// +public static class ResolutionChoices +{ + private static readonly ScreenSize[] Usual = + { + // 16:9 + new(3840, 2160), new(2560, 1440), new(1920, 1080), new(1600, 900), new(1366, 768), new(1280, 720), + // 16:10 + new(2560, 1600), new(1920, 1200), new(1680, 1050), new(1440, 900), new(1280, 800), + // Ultrawide + new(3440, 1440), new(2560, 1080), + // 5:4 and 4:3 + new(1280, 1024), new(1024, 768) + }; + + /// The monitors, the primary one first; empty where they cannot be told. + /// The size asked for now, kept on the list whatever it is. + public static IReadOnlyList For(IReadOnlyCollection monitors, ScreenSize current) + { + var known = monitors.Where(IsReal).Distinct().ToList(); + var rest = Usual.Where(size => known.Count == 0 || known.Any(m => Fits(size, m))); + if (IsReal(current)) + rest = rest.Append(current); + + return known.Select(size => new ResolutionChoice(size, true)) + .Concat(rest.Except(known).Distinct() + .OrderByDescending(size => size.Width) + .ThenByDescending(size => size.Height) + .Select(size => new ResolutionChoice(size, false))) + .ToList(); + } + + private static bool IsReal(ScreenSize size) => size.Width > 0 && size.Height > 0; + + private static bool Fits(ScreenSize size, ScreenSize monitor) => + size.Width <= monitor.Width && size.Height <= monitor.Height; +} diff --git a/StarterNG/Domain/Settings/SettingReach.cs b/StarterNG/Domain/Settings/SettingReach.cs new file mode 100644 index 0000000..b00af23 --- /dev/null +++ b/StarterNG/Domain/Settings/SettingReach.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; + +namespace StarterNG.Domain.Settings; + +/// Why the simulator about to be started would pass a key over. +public enum IdleReason +{ + None, + WindowsBuildOnly, + LegacyRendererOnly, + /// Read by the full OpenGL renderer alone. + ModernRendererOnly, + /// Read by the OpenGL renderers, which the experimental one is not. + OpenGlRenderersOnly, + NeedsNetworkServer, + /// The draw range is only stretched by the frame rate regulator, which is off. + NeedsFrameRateTarget, + /// fullscreenwindowed takes the monitor's own mode instead. + MonitorModeInstead +} + +/// What about the simulator to be started decides which keys it heeds. +/// The executable is a Windows build, under Wine or not. +/// The gfxrenderer word, as has it. +/// eu07.ini starts a network.server. +/// targetfps as eu07.ini sets it, or null where it does not. +/// fpsaverage, the older regulator's target; 0 for none. +/// fullscreenwindowed is on. +public sealed record SimulatorTarget(bool WindowsBuild, string Renderer, bool NetworkServer, + double? TargetFps = null, double FpsAverage = 0, + bool FullscreenWindowed = false); + +/// The renderers a gfxrenderer word picks, as application.cpp tells them apart. +public enum RendererKind { Modern, Legacy, Experimental } + +/// +/// Where an eu07.ini key takes effect, from the simulator's sources: a key it +/// reads but leaves alone on the platform, render path or setup at hand. +/// +public static class SettingReach +{ + // The keyboard lights, LPT and PoKeys behind feedback live in Console.cpp, + // which only the Windows build compiles; the system console and ANGLE are + // Windows-only in application.cpp. + private static readonly HashSet WindowsBuildOnly = new(StringComparer.OrdinalIgnoreCase) + { + "feedbackmode", "feedbackport", "showsystemconsole", "gfx.angleplatform" + }; + + // Read by openglrenderer.cpp alone; the modern path forces VBOs on. + private static readonly HashSet LegacyRendererOnly = new(StringComparer.OrdinalIgnoreCase) + { + "smoothtraction", "gfx.shadows.cab.range", "usevbo" + }; + + // Read by opengl33renderer.cpp and the shaders it builds (glsl_common.cpp) alone. + private static readonly HashSet ModernRendererOnly = new(StringComparer.OrdinalIgnoreCase) + { + "gfx.postfx.chromaticaberration.enabled", "gfx.postfx.motionblur.enabled", + "gfx.postfx.motionblur.shutter", "gfx.extraeffects", "gfx.envmap.enabled", "gfx.skippipeline", + "rendercab", "sleeperdistance" + }; + + // Read by both OpenGL renderers but not by the experimental one, which sets its + // own anisotropy, draws no smoke and has no frame rate regulator. + private static readonly HashSet OpenGlRenderersOnly = new(StringComparer.OrdinalIgnoreCase) + { + "anisotropicfiltering", "multisampling", "dynamiclights", "gfx.smoke", "gfx.smoke.fidelity", + "gfx.reflections.framerate", "gfx.reflections.range_vehicles", "gfx.shadow.angle.min", + "scalespeculars", "gfx.drawrange.factor.max" + }; + + // application.cpp: fullscreenwindowed takes its size from the monitor and always + // goes full screen. "width, height" is how the settings screen names that row. + private static readonly HashSet OverriddenByMonitorMode = new(StringComparer.OrdinalIgnoreCase) + { + "width, height", "fullscreen" + }; + + public static IdleReason Why(string key, SimulatorTarget target) + { + // The starter's own keys mean nothing to any simulator, and need not. + if (key.StartsWith("starter.", StringComparison.OrdinalIgnoreCase)) + return IdleReason.None; + if (WindowsBuildOnly.Contains(key) && !target.WindowsBuild) + return IdleReason.WindowsBuildOnly; + if (OverriddenByMonitorMode.Contains(key) && target.FullscreenWindowed) + return IdleReason.MonitorModeInstead; + + var renderer = KindOf(target.Renderer); + if (LegacyRendererOnly.Contains(key) && renderer != RendererKind.Legacy) + return IdleReason.LegacyRendererOnly; + if (ModernRendererOnly.Contains(key) && renderer != RendererKind.Modern) + return IdleReason.ModernRendererOnly; + if (OpenGlRenderersOnly.Contains(key) && renderer == RendererKind.Experimental) + return IdleReason.OpenGlRenderersOnly; + + // on_focus_change pauses only a simulator that serves the network. + if (string.Equals(key, "inactivepause", StringComparison.OrdinalIgnoreCase) && !target.NetworkServer) + return IdleReason.NeedsNetworkServer; + if (string.Equals(key, "gfx.drawrange.factor.max", StringComparison.OrdinalIgnoreCase) && + !DrawRangeRegulated(target)) + return IdleReason.NeedsFrameRateTarget; + return IdleReason.None; + } + + /// + /// Whether the renderer moves the draw range with the frame rate - the only use + /// of gfx.drawrange.factor.max: targetfps does it, as the older fpsaverage does. + /// + private static bool DrawRangeRegulated(SimulatorTarget target) => + target.FpsAverage != 0 || (target.TargetFps ?? 0) != 0; + + /// "full" and "default" are the modern renderer, "experimental" its own; every other word the legacy one. + public static RendererKind KindOf(string renderer) => renderer.ToLowerInvariant() switch + { + "full" or "default" => RendererKind.Modern, + "experimental" => RendererKind.Experimental, + _ => RendererKind.Legacy + }; +} diff --git a/StarterNG/Domain/Settings/SimulatorKeys.cs b/StarterNG/Domain/Settings/SimulatorKeys.cs new file mode 100644 index 0000000..c391fe2 --- /dev/null +++ b/StarterNG/Domain/Settings/SimulatorKeys.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace StarterNG.Domain.Settings; + +public enum SimulatorKeyKind { Toggle, Number, Text } + +/// +/// An eu07.ini key the simulator reads that needs no logic of the starter's own: +/// a switch, a number or a word, shown as one row of the settings. The default +/// is the simulator's own, from Globals.h, used while the file does not carry +/// the key. +/// +public sealed record SimulatorKey( + string Key, + SimulatorKeyKind Kind, + string Default, + string Category, + string Section, + double Minimum = 0, + double Maximum = 0, + double Step = 1, + string Unit = "", + bool ZeroIsOff = false, + int Repeat = 1) +{ + /// The label's translation key; the explanation's adds "Desc". + public string LabelKey => "Ini." + Key; + + public string DescriptionKey => LabelKey + ".Desc"; + + public static bool ParseToggle(string raw) => + FirstToken(raw).ToLowerInvariant() is "yes" or "true" or "1" or "tak"; + + public static string FormatToggle(bool value) => value ? "yes" : "no"; + + /// The key's value in , in the file's words: what is set, or else the default. + public string RawIn(SimulatorSettings s) => s.Extra.TryGetValue(Key, out string? raw) ? raw : Default; + + public double ParseNumber(string raw) => + double.TryParse(FirstToken(raw), NumberStyles.Float, CultureInfo.InvariantCulture, out double value) + ? value + : double.Parse(Default, CultureInfo.InvariantCulture); + + /// + /// Shake has a factor per axis; the row moves them together, so a number is + /// written as many times as the key takes it. + /// + public string FormatNumber(double value) + { + string one = value.ToString("0.####", CultureInfo.InvariantCulture); + return Repeat <= 1 ? one : string.Join(' ', Enumerable.Repeat(one, Repeat)); + } + + private static string FirstToken(string raw) + { + var tokens = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return tokens.Length > 0 ? tokens[0] : string.Empty; + } +} + +public static class SimulatorKeys +{ + public const string General = "general"; + public const string Graphics = "graphics"; + public const string Sound = "sound"; + public const string Physics = "physics"; + public const string Advanced = "advanced"; + + private static SimulatorKey Toggle(string key, bool value, string category, string section) => + new(key, SimulatorKeyKind.Toggle, SimulatorKey.FormatToggle(value), category, section); + + private static SimulatorKey Number(string key, double value, string category, string section, + double min, double max, double step, string unit = "", + bool zeroIsOff = false, int repeat = 1) => + new(key, SimulatorKeyKind.Number, value.ToString(CultureInfo.InvariantCulture), category, section, + min, max, step, unit, zeroIsOff, repeat); + + private static SimulatorKey Text(string key, string category, string section) => + new(key, SimulatorKeyKind.Text, string.Empty, category, section); + + public static SimulatorKey Named(string key) => All.First(k => k.Key == key); + + public static IReadOnlyList All { get; } = new[] + { + Number("ui.scale", 1, General, "IniSectionInterface", 0.5, 3, 0.05, "×"), + Number("ui.fontsize", 13, General, "IniSectionInterface", 8, 32, 1, " px"), + Number("ui.bg.opacity", 0.94, General, "IniSectionInterface", 0.1, 1, 0.05), + Toggle("gui.defaultwindows", true, General, "IniSectionInterface"), + Toggle("gui.trainingdefault", false, General, "IniSectionInterface"), + Toggle("gui.showtranscripts", true, General, "IniSectionInterface"), + Toggle("loadinglog", false, General, "IniSectionInterface"), + Toggle("captureonstart", true, General, "IniSectionInterface"), + Toggle("mousecontrol", true, General, "IniSectionInterface"), + Text("fullscreenmonitor", General, "IniSectionInterface"), + Text("screenshotsdir", General, "IniSectionInterface"), + + Number("basedrawrange", 2500, Graphics, "IniSectionRanges", 500, 10000, 250, " m"), + Number("heightbase", 1, Graphics, "IniSectionRanges", 0.5, 3, 0.05, "×"), + Number("targetfps", 0, Graphics, "IniSectionRanges", 0, 240, 5, " FPS", zeroIsOff: true), + Number("sleeperdistance", 250, Graphics, "IniSectionRanges", 0, 5000, 50, " m", zeroIsOff: true), + Number("gfx.reflections.range_instances", 750, Graphics, "IniSectionRanges", 0, 3000, 50, " m"), + Number("gfx.reflections.range_vehicles", 250, Graphics, "IniSectionRanges", 0, 2000, 50, " m"), + Toggle("gfx.textures.streaming", false, Graphics, "IniSectionRanges"), + Number("gfx.textures.releasedistance", 0, Graphics, "IniSectionRanges", 0, 10, 0.5, "×", zeroIsOff: true), + Toggle("skyenabled", true, Graphics, "IniSectionRanges"), + Toggle("rendercab", true, Graphics, "IniSectionRanges"), + Number("gfx.postfx.motionblur.shutter", 0.01, Graphics, "IniSectionRanges", 0.001, 0.1, 0.001, " s"), + Number("python.fpslimit", 100, Graphics, "IniSectionRanges", 5, 200, 5, " FPS"), + Toggle("python.vsync", true, Graphics, "IniSectionRanges"), + Toggle("python.displaywindows", false, Graphics, "IniSectionRanges"), + Toggle("newaircouplers", true, Graphics, "IniSectionRanges"), + Toggle("smoothtraction", true, Graphics, "IniSectionRanges"), + + Number("sound.maxsources", 30, Sound, "IniSectionAudio", 8, 256, 8), + Toggle("sound.asyncload", false, Sound, "IniSectionAudio"), + + Toggle("loadtraction", true, Physics, "IniSectionPhysics"), + Toggle("rollfix", true, Physics, "IniSectionPhysics"), + Number("shakefactor", 1, Physics, "IniSectionPhysics", 0, 3, 0.1, "×", repeat: 3), + Toggle("map.manualswitchcontrol", false, Physics, "IniSectionPhysics"), + Number("map.highlightdistance", 3000, Physics, "IniSectionPhysics", 0, 10000, 250, " m", zeroIsOff: true), + + Toggle("showsystemconsole", false, Advanced, "IniSectionConsole"), + }; +} diff --git a/StarterNG/Domain/Settings/SimulatorSettings.cs b/StarterNG/Domain/Settings/SimulatorSettings.cs index e1dea3c..5c22ad9 100644 --- a/StarterNG/Domain/Settings/SimulatorSettings.cs +++ b/StarterNG/Domain/Settings/SimulatorSettings.cs @@ -1,16 +1,23 @@ using System; +using System.Collections.Generic; namespace StarterNG.Domain.Settings; // Values match the key eu07.ini has carried since the old starter. public enum ConsistReadyOverride { Scenery = 0, AlwaysCold = 1, AlwaysReady = 2 } -public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform } +public enum ExeProblem { None, NotFound, NotExecutable, WrongPlatform, NotAProgram } public sealed class SimulatorSettings { public bool LanguageWasSet { get; internal set; } + /// eu07.ini starts a network.server, which the starter leaves to the file. + public bool NetworkServer { get; internal set; } + + /// fpsaverage: the older regulator's frame rate target, which the starter leaves as it is. + public double FpsAverage { get; internal set; } + public string Language = "English"; public string LanguageCode = ""; @@ -26,16 +33,20 @@ public sealed class SimulatorSettings public int FeedbackPort = 888; public bool UartEnabled; public string UartPort = ""; + public int UartBaud = Uart.DefaultBaud; + public double UartUpdateTime = Uart.DefaultUpdateTime; public string UartTune = ""; public bool UartDebug; public bool UartMain, UartScnd, UartTrain, UartLocal, UartRadioVolume, UartRadioChannel; + /// The simulator's dynamicbrake feature, which has no box in the starter: kept as the file has it. + public bool UartDynamicBrake; + public bool SelectExeAutomatically = true; public string ExecutablePath = "eu07.exe"; public bool DebugMode; public bool VirtualShunting = true; public bool LogMissingVehicleFiles; - public bool DeveloperTools; public int RenderEngine; public int Width = 1280; @@ -43,7 +54,7 @@ public sealed class SimulatorSettings public int BufferScalePercent = 100; public int MaxTextureSize = 4096; public int MaxCabTextureSize = 4096; - public int TextureFiltering = 8; + public double TextureFiltering = 8; public int Multisampling = 2; public int DynamicLights = 7; public double DrawRangeFactor = 3.0; @@ -56,8 +67,10 @@ public sealed class SimulatorSettings public bool EnvMap = true; public bool UseVbo = true; public bool RenderShadows = true; - public int ReflectionsFramerate = 60; + /// Updates of the reflections a second; the simulator's own is one in five minutes. + public double ReflectionsFramerate = 1 / 300.0; public int ShadowMapResolution = 2048; + /// The third number of shadowtune; the second and fourth go unread. public int ShadowProjectionRange = 250; public int CabShadowsRange; public int ShadowRankCutoff = 3; @@ -66,7 +79,7 @@ public sealed class SimulatorSettings public bool PythonScreens = true; public bool PythonThreadedUpload = true; public bool FpsLimitEnabled; - public int FpsLimit = 30; + public double FpsLimit = 30; public double ShadowAngleLimit = 0.2; public bool FullscreenWindowed; public bool RenderAngleVulkan; @@ -110,6 +123,12 @@ public sealed class SimulatorSettings public bool LargeThumbnails; public bool AutoExpandSceneryTree; + /// Warn at start when the processor cannot run the official simulator builds. + public bool CpuNotice = true; + + /// The card a native Linux simulator is started on, on a machine with two. + public GpuPreference Gpu = GpuPreference.Discrete; + public ConsistReadyOverride ReadyOverride = ConsistReadyOverride.Scenery; public bool ShowAiVehicles = true; @@ -120,12 +139,62 @@ public sealed class SimulatorSettings public bool WindowMaximized; - public static readonly string[] RenderEngines = - { "full", "legacy", "simpleshader", "simple", "off", "experimental" }; + /// + /// The as eu07.ini carries them, by key. One the + /// file does not set is absent, and stays out of the file until changed. + /// + public Dictionary Extra { get; private set; } = new(StringComparer.OrdinalIgnoreCase); - public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 }; + /// A copy to try changes on, apart from these settings. + public SimulatorSettings Copy() + { + var copy = (SimulatorSettings)MemberwiseClone(); + copy.Extra = new Dictionary(Extra, StringComparer.OrdinalIgnoreCase); + copy.ShadowTuneUnused = (string[])ShadowTuneUnused.Clone(); + return copy; + } + + /// + /// The gfxrenderer words the simulator tells apart (application.cpp): every + /// other word - the "simpleshader" and "off" older starters offered among them - + /// gets the legacy renderer. + /// + public static readonly string[] RenderEngines = { "full", "legacy", "simple", "experimental" }; + + /// Where stands among , as the simulator reads it. + public static int RenderEngineOf(string word) => word.Trim().ToLowerInvariant() switch + { + "full" or "default" => 0, + "simple" => 2, + "experimental" => 3, + _ => 1 + }; + + /// + /// The least shadow range the simulator keeps for a map size: a map above + /// 2048 px stretches over proportionally more ground. + /// + public static int ShadowRangeFloor(int mapSize) => mapSize <= 2048 ? 75 : 75 * mapSize / 2048; + + /// + /// How far shadows actually reach. The full renderer's cascades stretch with a + /// map above 2048 px (opengl33renderer.cpp), on top of the floor; the legacy + /// one draws a single map out to the range itself. + /// + public static int ShadowReach(int range, int mapSize, bool legacyRenderer) + { + int reach = Math.Max(range, ShadowRangeFloor(mapSize)); + return legacyRenderer || mapSize <= 2048 ? reach : reach * (mapSize / 2048); + } + + /// + /// The file's own first mousescale number. The slider takes whole steps: this + /// stands for as long as is the step nearest it. + /// + internal double MouseScaleXMagnitude { get; set; } = 2; internal double MouseScaleYMagnitude { get; set; } = 0.2; - internal string[] ShadowTuneExtra { get; set; } = { "400", "300" }; + /// The numbers of shadowtune the simulator skips, kept as the file has them. + internal string[] ShadowTuneUnused { get; set; } = { "250", "300" }; } diff --git a/StarterNG/Domain/Settings/Steps.cs b/StarterNG/Domain/Settings/Steps.cs new file mode 100644 index 0000000..8ce5c32 --- /dev/null +++ b/StarterNG/Domain/Settings/Steps.cs @@ -0,0 +1,59 @@ +using System; +using System.Collections.Generic; + +namespace StarterNG.Domain.Settings; + +/// +/// Values a setting is chosen from, for a number the simulator takes any value +/// of but only some of which are worth offering. +/// +public static class Steps +{ + /// fpslimit: none, then from a laptop on battery up to the fastest monitors. + public static readonly double[] FrameRateLimits = + { 0, 20, 25, 30, 40, 45, 50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 200, 240, 360 }; + + /// + /// gfx.reflections.framerate, in updates a second: from the simulator's own + /// once in five minutes up to every frame. + /// + public static readonly double[] ReflectionUpdates = + { 1 / 300.0, 1 / 60.0, 1 / 30.0, 0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60 }; + + /// gfx.drawrange.factor.max: how far the regulator may stretch the draw range. + public static readonly double[] DrawRangeFactors = { 1, 1.5, 2, 2.5, 3 }; + + /// anisotropicfiltering: 16 is the most any card offers. + public static readonly double[] Anisotropy = { 1, 2, 4, 8, 16 }; + + /// + /// multisampling, as the power of two of the samples: none to x8. The simulator + /// takes 4 (x16) too, which only some cards can do - on one that cannot, the + /// frame buffer fails to build - so it stays in the file but off the slider. + /// + public static readonly double[] MultisamplingLevels = { 0, 1, 2, 3 }; + + /// + /// with among them: one + /// eu07.ini has that is none of the steps stays what it is. + /// + public static IReadOnlyList With(IReadOnlyList steps, double value) + { + if (IndexOf(steps, value) >= 0 || !double.IsFinite(value)) + return steps; + + var list = new List(steps); + int at = list.FindIndex(step => step > value); + list.Insert(at < 0 ? list.Count : at, value); + return list; + } + + /// The step that is , give or take its rounding in the file. + public static int IndexOf(IReadOnlyList steps, double value) + { + for (int i = 0; i < steps.Count; i++) + if (Math.Abs(steps[i] - value) <= Math.Abs(steps[i]) * 2e-4) + return i; + return -1; + } +} diff --git a/StarterNG/Domain/Settings/Uart.cs b/StarterNG/Domain/Settings/Uart.cs new file mode 100644 index 0000000..fcedf82 --- /dev/null +++ b/StarterNG/Domain/Settings/Uart.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace StarterNG.Domain.Settings; + +/// What the simulator takes after its uart keys. +public static class Uart +{ + /// The speeds the simulator offers in its own UART panel. + public static readonly int[] BaudRates = + { 300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 74880, 115200, 230400, 250000, 500000, 1000000, 2000000 }; + + // The simulator has none of its own: "uart" must name all three. + public const int DefaultBaud = 57600; + public const double DefaultUpdateTime = 0.1; + + public const int TuneValues = 18; + + /// + /// The calibration as the simulator reads it - eighteen numbers - or null. + /// With fewer, it takes the next keys of the file in their place. + /// + public static string? NormalizeTune(string? text) + { + var values = (text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return values.Length == TuneValues && values.All(IsNumber) + ? string.Join(' ', values) + : null; + } + + /// + /// The file's uarttune line as a calibration to show. With a word in it, it holds + /// no calibration at all but the next keys of the file, where the line was left short. + /// + public static string TuneInFile(string? text) => + (text ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).All(IsNumber) ? text ?? "" : ""; + + private static bool IsNumber(string word) => + double.TryParse(word, NumberStyles.Float, CultureInfo.InvariantCulture, out _); + + /// The simulator reads the port as one word. + public static bool IsPortName(string? port) => + !string.IsNullOrWhiteSpace(port) && !port.Trim().Any(char.IsWhiteSpace); + + /// The interval as its box can hold it, whatever the file says. + public static decimal ShownUpdateTime(double seconds) => + double.IsNaN(seconds) ? (decimal)DefaultUpdateTime : (decimal)Math.Clamp(seconds, 0, MaxShownUpdateTime); + + public const double MaxShownUpdateTime = 10; + + /// + /// uartfeature with nothing selected. Every build of the simulator reads the four + /// words, only newer ones "none"; the features they do not name are off by default. + /// + public const string NoFeatures = "no no no no"; + + // In the order the older form's four words go by. + private static readonly string[] FeatureNames = + { "main", "scnd", "train", "local", "radiovolume", "radiochannel", "dynamicbrake" }; + + private const int OldFormWords = 4; + + /// + /// The features a uartfeature value has on, as Globals.cpp reads it: a first word + /// with a pipe, "none" or a feature's name is a list; anything else is the older + /// form, four yes-or-no words for the first four features. Bare, nothing is on. + /// + public static ISet FeaturesOn(string? value) + { + var words = (value ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + var on = new HashSet(StringComparer.OrdinalIgnoreCase); + if (words.Length == 0) + return on; + + if (IsFeatureList(words[0])) + { + foreach (string name in words[0].Split('|', StringSplitOptions.RemoveEmptyEntries)) + if (FeatureNames.Contains(name, StringComparer.OrdinalIgnoreCase)) + on.Add(name); + } + else + { + for (int i = 0; i < Math.Min(OldFormWords, words.Length); i++) + if (words[i].ToLowerInvariant() is "yes" or "true" or "1") + on.Add(FeatureNames[i]); + } + return on; + } + + /// + /// Whether the file's own uartfeature line can stay in place of the one that would + /// be written: it means the same, and every simulator reads it whole. The older + /// form short of its words takes the next keys in; an empty list is what + /// is for. + /// + public static bool FeaturesStand(string? inFile, string? written) + { + if (written is null || string.IsNullOrWhiteSpace(inFile)) + return false; + + var on = FeaturesOn(inFile); + if (!on.SetEquals(FeaturesOn(written))) + return false; + + var words = inFile.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + return IsFeatureList(words[0]) ? on.Count > 0 : words.Length >= OldFormWords; + } + + private static bool IsFeatureList(string word) => + word.Contains('|') || + word.Equals("none", StringComparison.OrdinalIgnoreCase) || + FeatureNames.Contains(word, StringComparer.OrdinalIgnoreCase); +} diff --git a/StarterNG/Infrastructure/CpuFeatures.cs b/StarterNG/Infrastructure/CpuFeatures.cs new file mode 100644 index 0000000..aa750ef --- /dev/null +++ b/StarterNG/Infrastructure/CpuFeatures.cs @@ -0,0 +1,16 @@ +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics.X86; + +namespace StarterNG.Infrastructure; + +/// What the processor offers against what the simulator builds assume. +public static class CpuFeatures +{ + /// + /// The simulator's CMakeLists has ENABLE_AVX2 on by default since May 2026, so + /// 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 => + RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86 && !Avx2.IsSupported; +} diff --git a/StarterNG/Infrastructure/Displays.cs b/StarterNG/Infrastructure/Displays.cs new file mode 100644 index 0000000..9e6bfc9 --- /dev/null +++ b/StarterNG/Infrastructure/Displays.cs @@ -0,0 +1,188 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; +using StarterNG.Application.Abstractions; + +namespace StarterNG.Infrastructure; + +/// A screen, and the fastest it refreshes. +/// The connector on Linux (eDP-1, HDMI-A-1); empty for the primary screen on Windows. +public sealed record Display(string Name, int RefreshRate); + +/// +/// The screens attached, for a frame rate limit to match: on Linux from the EDID +/// each connected connector of /sys/class/drm passes on - the most the panel can +/// do, which the desktop may not be using - and on Windows the primary screen's +/// current mode. +/// +public sealed class Displays +{ + private const string DrmClass = "/sys/class/drm"; + + private static readonly Regex Connector = new(@"^card\d+-(.+)$", RegexOptions.CultureInvariant); + + private readonly IFileSystem _files; + private readonly IEnvironment _environment; + + public Displays(IFileSystem files, IEnvironment environment) + { + _files = files; + _environment = environment; + } + + /// Every screen whose refresh rate could be read; empty where none could. + public IReadOnlyList List() + { + try + { + if (_environment.IsLinux) + return FromDrm(); + if (_environment.IsWindows && OperatingSystem.IsWindows()) + return PrimaryOnWindows() is { } rate ? new[] { new Display(string.Empty, rate) } : Array.Empty(); + } + catch (Exception) + { + // A screen we cannot read leaves the limit to the player, as before. + } + + return Array.Empty(); + } + + private IReadOnlyList FromDrm() + { + var displays = new List(); + foreach (string directory in _files.GetDirectories(DrmClass)) + { + var match = Connector.Match(Path.GetFileName(directory)); + if (!match.Success || match.Groups[1].Value.StartsWith("Writeback", StringComparison.Ordinal)) + continue; + + try + { + string status = Path.Combine(directory, "status"); + if (!_files.FileExists(status) || _files.ReadAllText(status).Trim() != "connected") + continue; + int rate = Edid.HighestRefreshRate(_files.ReadAllBytes(Path.Combine(directory, "edid"))); + if (rate > 0) + displays.Add(new Display(match.Groups[1].Value, rate)); + } + catch (Exception) + { + // One connector we cannot read says nothing of the others. + } + } + + return displays; + } + + // DEVMODEW: dmSize at 68, dmDisplayFrequency at 184, 220 bytes with no driver data. + private const int DevModeSize = 220; + private const int DevModeSizeOffset = 68; + private const int DisplayFrequencyOffset = 184; + private const int CurrentSettings = -1; + + [DllImport("user32.dll", ExactSpelling = true)] + private static extern int EnumDisplaySettingsW(IntPtr deviceName, int modeNum, IntPtr devMode); + + private static int? PrimaryOnWindows() + { + IntPtr mode = Marshal.AllocHGlobal(DevModeSize); + try + { + for (int i = 0; i < DevModeSize; i++) + Marshal.WriteByte(mode, i, 0); + Marshal.WriteInt16(mode, DevModeSizeOffset, DevModeSize); + if (EnumDisplaySettingsW(IntPtr.Zero, CurrentSettings, mode) == 0) + return null; + int rate = Marshal.ReadInt32(mode, DisplayFrequencyOffset); + // 0 and 1 stand for the hardware's default rate. + return rate > 1 ? rate : null; + } + finally + { + Marshal.FreeHGlobal(mode); + } + } +} + +/// The timings an EDID lists, as far as a refresh rate goes. +public static class Edid +{ + /// The fastest refresh rate among the detailed timings, rounded; 0 for none. + public static int HighestRefreshRate(byte[] edid) + { + if (edid.Length < 128) + return 0; + + // The base block's four descriptors. + double best = 0; + for (int at = 54; at + 18 <= 126; at += 18) + best = Math.Max(best, DetailedTiming(edid, at)); + + for (int block = 128; block + 128 <= edid.Length; block += 128) + { + switch (edid[block]) + { + // CTA-861: detailed timings from the offset in its third byte on. + case 0x02 when edid[block + 2] >= 4: + for (int at = block + edid[block + 2]; at + 18 <= block + 127; at += 18) + best = Math.Max(best, DetailedTiming(edid, at)); + break; + // DisplayID, where a panel faster than 60 Hz often keeps its only fast mode. + case 0x70: + best = Math.Max(best, DisplayId(edid, block)); + break; + } + } + + return (int)Math.Round(best); + } + + // An 18-byte detailed timing; a pixel clock of zero marks some other descriptor. + private static double DetailedTiming(byte[] e, int at) + { + int clock = e[at] | e[at + 1] << 8; + if (clock == 0) + return 0; + + int hActive = e[at + 2] | (e[at + 4] & 0xF0) << 4, hBlank = e[at + 3] | (e[at + 4] & 0x0F) << 8; + int vActive = e[at + 5] | (e[at + 7] & 0xF0) << 4, vBlank = e[at + 6] | (e[at + 7] & 0x0F) << 8; + return Rate(clock * 10_000.0, hActive + hBlank, vActive + vBlank); + } + + // Data blocks from the sixth byte on; type I timings (DisplayID 1.3) count the + // pixel clock in 10 kHz, type VII (2.0) in kHz, and both store each size less one. + private static double DisplayId(byte[] e, int block) + { + double best = 0; + int end = Math.Min(block + 5 + e[block + 2], block + 127); + for (int at = block + 5; at + 3 <= end;) + { + int tag = e[at], length = e[at + 2]; + if (tag == 0 && length == 0) + break; + + int payload = at + 3; + if (tag is 0x03 or 0x22) + { + for (int t = payload; t + 20 <= Math.Min(payload + length, end); t += 20) + { + double clock = (e[t] | e[t + 1] << 8 | e[t + 2] << 16) + 1; + clock *= tag == 0x03 ? 10_000 : 1_000; + int hTotal = (e[t + 4] | e[t + 5] << 8) + 1 + (e[t + 6] | e[t + 7] << 8) + 1; + int vTotal = (e[t + 12] | e[t + 13] << 8) + 1 + (e[t + 14] | e[t + 15] << 8) + 1; + best = Math.Max(best, Rate(clock, hTotal, vTotal)); + } + } + + at = payload + length; + } + + return best; + } + + private static double Rate(double pixelClock, int hTotal, int vTotal) => + hTotal > 0 && vTotal > 0 ? pixelClock / ((double)hTotal * vTotal) : 0; +} diff --git a/StarterNG/Infrastructure/LinuxGpus.cs b/StarterNG/Infrastructure/LinuxGpus.cs new file mode 100644 index 0000000..d42a29d --- /dev/null +++ b/StarterNG/Infrastructure/LinuxGpus.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using StarterNG.Application.Abstractions; +using StarterNG.Domain.Settings; + +namespace StarterNG.Infrastructure; + +/// +/// The graphics cards of a Linux machine, read from /sys/class/drm - which the +/// Steam Linux Runtime container shows too, unlike some of the host's tools. +/// +public sealed class LinuxGpus +{ + private const string DrmClass = "/sys/class/drm"; + + // card0 and card1, not their connectors (card0-eDP-2) or render nodes. + private static readonly Regex Card = new(@"^card\d+$", RegexOptions.CultureInvariant); + + // The connectors of a laptop's own screen, listed whether lit or not. + private static readonly Regex Panel = new(@"^(card\d+)-(eDP|LVDS|DSI)-\d+$", RegexOptions.CultureInvariant); + + private readonly IFileSystem _files; + private readonly IEnvironment _environment; + + public LinuxGpus(IFileSystem files, IEnvironment environment) + { + _files = files; + _environment = environment; + } + + /// Every card with a PCI address and a driver; empty off Linux, or when /sys cannot be read. + public IReadOnlyList List() + { + if (!_environment.IsLinux) + return Array.Empty(); + + try + { + var gpus = new List(); + var entries = _files.GetDirectories(DrmClass).Select(Path.GetFileName).OfType().ToList(); + var withPanel = entries.Select(e => Panel.Match(e)).Where(m => m.Success) + .Select(m => m.Groups[1].Value).ToHashSet(StringComparer.Ordinal); + foreach (string name in entries.Where(e => Card.IsMatch(e))) + { + string card = Path.Combine(DrmClass, name); + string device = Path.Combine(card, "device"); + string uevent = Path.Combine(device, "uevent"); + if (!_files.FileExists(uevent)) + continue; + + var fields = _files.ReadAllLines(uevent) + .Select(line => line.Split('=', 2)) + .Where(pair => pair.Length == 2) + .ToDictionary(pair => pair[0], pair => pair[1].Trim(), StringComparer.Ordinal); + if (!fields.TryGetValue("PCI_SLOT_NAME", out string? slot) || + !fields.TryGetValue("PCI_ID", out string? id)) + continue; + + string bootVga = Path.Combine(device, "boot_vga"); + bool boot = _files.FileExists(bootVga) && _files.ReadAllText(bootVga).Trim() == "1"; + gpus.Add(new GpuDevice(slot, id, fields.GetValueOrDefault("DRIVER", ""), boot, withPanel.Contains(name))); + } + + return gpus.OrderBy(g => g.Slot, StringComparer.Ordinal).ToList(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException) + { + return Array.Empty(); + } + } +} diff --git a/StarterNG/Infrastructure/SerialPorts.cs b/StarterNG/Infrastructure/SerialPorts.cs new file mode 100644 index 0000000..61cc9f6 --- /dev/null +++ b/StarterNG/Infrastructure/SerialPorts.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.Versioning; + +namespace StarterNG.Infrastructure; + +/// The serial ports present now, named the way the simulator's uart key wants them. +public static class SerialPorts +{ + // USB adapters and boards; the legacy ttyS ports exist on every PC whether + // wired or not, so they would only crowd the list. + private static readonly string[] UnixPatterns = { "ttyUSB*", "ttyACM*", "ttyAMA*", "rfcomm*", "cu.*" }; + + public static IReadOnlyList List() + { + try + { + if (OperatingSystem.IsWindows()) + return FromRegistry(); + + return UnixPatterns + .SelectMany(pattern => Directory.GetFiles("/dev", pattern)) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + return Array.Empty(); + } + } + + [SupportedOSPlatform("windows")] + private static IReadOnlyList FromRegistry() + { + using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"HARDWARE\DEVICEMAP\SERIALCOMM"); + if (key is null) + return Array.Empty(); + var ports = new List(); + foreach (string name in key.GetValueNames()) + if (key.GetValue(name) is string port) + ports.Add(port); + // COM3 before COM10. + return ports.OrderBy(p => p.Length).ThenBy(p => p, StringComparer.OrdinalIgnoreCase).ToList(); + } +} diff --git a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs index cf580d2..dbb1a9b 100644 --- a/StarterNG/Infrastructure/Settings/ExecutableLocator.cs +++ b/StarterNG/Infrastructure/Settings/ExecutableLocator.cs @@ -124,6 +124,9 @@ public sealed class ExecutableLocator if (!_files.FileExists(path)) return ExeProblem.NotFound; + if (_environment.IsLinux && Format(path) == BinaryFormat.Unknown) + return ExeProblem.NotAProgram; + bool foreign = !MatchesPlatform(path); // Started through Wine, a Windows build needs no execute bit of its own. @@ -183,7 +186,8 @@ public sealed class ExecutableLocator return null; } - private bool RunsThroughWine(string path) + /// Whether is a Windows build the starter hands to Wine. + public bool RunsThroughWine(string path) { try { @@ -206,7 +210,8 @@ public sealed class ExecutableLocator { Unknown, Elf, - PortableExecutable + PortableExecutable, + Script } private BinaryFormat Format(string path) @@ -220,10 +225,26 @@ public sealed class ExecutableLocator return BinaryFormat.PortableExecutable; if (head[0] == 0x7F && head[1] == 0x45 && head[2] == 0x4C && head[3] == 0x46) return BinaryFormat.Elf; + if (head[0] == (byte)'#' && head[1] == (byte)'!') + return BinaryFormat.Script; return BinaryFormat.Unknown; } + // Linux starts nothing else, and on a Windows partition every file has the execute + // bit: only what is inside tells a program from a note called eu07_todo. + private bool IsProgramOnLinux(string path) + { + try + { + return Format(path) != BinaryFormat.Unknown; + } + catch (Exception) + { + return false; + } + } + private bool IsExeOnLinux(string path) => _environment.IsLinux && string.Equals(Path.GetExtension(path), ".exe", StringComparison.OrdinalIgnoreCase); @@ -240,12 +261,34 @@ public sealed class ExecutableLocator // A Windows build is offered only where Wine can start it, and after any // native one - see the ordering in Resolve. if (_environment.IsLinux) - return extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage" || - (IsExeOnLinux(path) && Wine() is not null); + return (extension.Length == 0 || extension is ".x86_64" or ".run" or ".appimage" || + (IsExeOnLinux(path) && Wine() is not null)) && IsProgramOnLinux(path); return extension.Length == 0 || extension is ".exe" or ".x86_64" or ".run" or ".appimage"; } - private string FullPath(string path) => + /// A path as gives it, which may be relative to the installation. + public string FullPath(string path) => Path.IsPathRooted(path) ? path : Path.Combine(_paths.Root, path); + + /// + /// Whether the binary is a Windows build, which runs the Windows code of the + /// simulator even under Wine. One that cannot be read is taken to suit the host. + /// + public bool IsWindowsBuild(string path) + { + try + { + return Format(FullPath(path)) switch + { + BinaryFormat.PortableExecutable => true, + BinaryFormat.Elf => false, + _ => _environment.IsWindows + }; + } + catch (Exception) + { + return _environment.IsWindows; + } + } } diff --git a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs index 79260d8..4f54d74 100644 --- a/StarterNG/Infrastructure/Settings/SettingsSerializer.cs +++ b/StarterNG/Infrastructure/Settings/SettingsSerializer.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; @@ -17,13 +16,16 @@ public sealed class SettingsSerializer s.Language = s.LanguageCode; s.Fullscreen = c.GetBool("fullscreen", false); s.PauseWhenInactive = c.GetBool("inactivepause", true); + s.NetworkServer = c.Has("network.server"); + s.FpsAverage = c.GetDouble("fpsaverage", 0); s.PauseOnStart = c.GetBool("pause", false); var mouse = c.GetTokens("mousescale"); if (mouse.Length >= 1 && double.TryParse(mouse[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double mx)) { s.InvertMouseHorizontal = mx < 0; - s.CursorSensitivity = Clamp((int)Math.Round(Math.Abs(mx)), 1, 5); + s.MouseScaleXMagnitude = Math.Abs(mx); + s.CursorSensitivity = SensitivityStep(mx); } if (mouse.Length >= 2 && double.TryParse(mouse[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double my)) { @@ -35,20 +37,27 @@ public sealed class SettingsSerializer s.FeedbackMode = Clamp(c.GetInt("feedbackmode", 1), 0, 5); s.FeedbackPort = c.GetInt("feedbackport", 888); s.UartEnabled = c.Has("uart"); - s.UartPort = c.GetString("uart", ""); - s.UartTune = c.GetString("uarttune", ""); + var uart = c.GetTokens("uart"); + s.UartPort = uart.ElementAtOrDefault(0) ?? ""; + s.UartBaud = ParseInt(uart.ElementAtOrDefault(1) ?? "", 0) is > 0 and var baud ? baud : Uart.DefaultBaud; + s.UartUpdateTime = uart.Length >= 3 && + double.TryParse(uart[2], NumberStyles.Float, CultureInfo.InvariantCulture, out double every) && + every >= 0 + ? every + : Uart.DefaultUpdateTime; + s.UartTune = Uart.TuneInFile(c.GetString("uarttune", "")); s.UartDebug = c.GetBool("uartdebug", false); if (c.Has("uartfeature")) { - var uartTokens = (c.GetRaw("uartfeature") ?? "").Split('|', StringSplitOptions.RemoveEmptyEntries); - var uf = new HashSet(uartTokens, StringComparer.OrdinalIgnoreCase); + var uf = Uart.FeaturesOn(c.GetRaw("uartfeature")); s.UartMain = uf.Contains("main"); s.UartScnd = uf.Contains("scnd"); s.UartTrain = uf.Contains("train"); s.UartLocal = uf.Contains("local"); s.UartRadioVolume = uf.Contains("radiovolume"); s.UartRadioChannel = uf.Contains("radiochannel"); + s.UartDynamicBrake = uf.Contains("dynamicbrake"); } else { s.UartMain = s.UartScnd = s.UartTrain = s.UartLocal = true; - s.UartRadioVolume = s.UartRadioChannel = false; + s.UartRadioVolume = s.UartRadioChannel = s.UartDynamicBrake = false; } s.SelectExeAutomatically = c.GetBool("starter.exe.auto", true); @@ -56,16 +65,15 @@ public sealed class SettingsSerializer s.DebugMode = c.GetBool("debugmode", false); s.VirtualShunting = c.GetBool("ai.trainman", true); 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.RenderEngine = SimulatorSettings.RenderEngineOf(c.GetString("gfxrenderer", "full")); s.Width = c.GetInt("width", 1280); s.Height = c.GetInt("height", 720); s.BufferScalePercent = Clamp(c.GetInt("starter.bufferscale", 100), 50, 200); s.MaxTextureSize = c.GetInt("maxtexturesize", 4096); s.MaxCabTextureSize = c.GetInt("maxcabtexturesize", 4096); - s.TextureFiltering = c.GetInt("anisotropicfiltering", 8); - s.Multisampling = Clamp(c.GetInt("multisampling", 2), 0, 3); + s.TextureFiltering = Math.Max(1, c.GetDouble("anisotropicfiltering", 8)); + s.Multisampling = Clamp(c.GetInt("multisampling", 2), 0, 4); 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); @@ -77,21 +85,27 @@ public sealed class SettingsSerializer s.EnvMap = c.GetBool("gfx.envmap.enabled", true); s.UseVbo = c.GetBool("usevbo", true); s.RenderShadows = c.GetBool("shadows", true); - s.ReflectionsFramerate = c.GetInt("gfx.reflections.framerate", 60); + // A negative rate counts as its size; none at all, as never. + double reflections = Math.Abs(c.GetDouble("gfx.reflections.framerate", 1 / 300.0)); + s.ReflectionsFramerate = reflections > 0 ? reflections : 1 / 300.0; + // map size, unread, projection range, unread var shadowTune = c.GetTokens("shadowtune"); if (shadowTune.Length >= 1) s.ShadowMapResolution = ParseInt(shadowTune[0], 2048); - if (shadowTune.Length >= 2) s.ShadowProjectionRange = ParseInt(shadowTune[1], 250); - if (shadowTune.Length >= 4) s.ShadowTuneExtra = new[] { shadowTune[2], shadowTune[3] }; + if (shadowTune.Length >= 3) s.ShadowProjectionRange = (int)Math.Round(ParseDouble(shadowTune[2], 250)); + if (shadowTune.Length >= 2) s.ShadowTuneUnused[0] = shadowTune[1]; + if (shadowTune.Length >= 4) s.ShadowTuneUnused[1] = shadowTune[3]; s.CabShadowsRange = (int)Math.Round(c.GetDouble("gfx.shadows.cab.range", 0)); s.ShadowRankCutoff = c.GetInt("gfx.shadow.rank.cutoff", 3); s.ReflectionsFidelity = Clamp(c.GetInt("gfx.reflections.fidelity", 0), 0, 2); - s.FieldOfView = Clamp((int)Math.Round(c.GetDouble("fieldofview", 45)), 15, 75); + s.FieldOfView = Clamp((int)Math.Round(c.GetDouble("fieldofview", 45)), 10, 75); s.PythonScreens = c.GetBool("python.enabled", true); s.PythonThreadedUpload = c.GetBool("python.threadedupload", true); - s.FpsLimitEnabled = c.Has("fpslimit"); - s.FpsLimit = Clamp(c.GetInt("fpslimit", 30), 1, 100); + // The simulator takes any rate above zero; at or below it, no limit. + double fpsLimit = c.GetDouble("fpslimit", 0); + s.FpsLimitEnabled = fpsLimit > 0; + if (s.FpsLimitEnabled) s.FpsLimit = fpsLimit; 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); @@ -113,11 +127,11 @@ public sealed class SettingsSerializer s.BrakeSpeed = c.GetDouble("brakespeed", 3.0); s.SoundEnabled = c.GetBool("soundenabled", true); - s.Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 1, 100); - s.RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 1, 100); - s.VehiclesVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.vehicle", 1.0) * 100), 1, 100); - s.PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 1.0) * 100), 1, 100); - s.AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 1, 100); + s.Volume = Clamp((int)Math.Round(c.GetDouble("sound.volume", 1.0) * 50), 0, 100); + s.RadioVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.radio", 0.75) * 100), 0, 100); + s.VehiclesVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.vehicle", 1.0) * 100), 0, 100); + s.PositionalVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.positional", 1.0) * 100), 0, 100); + s.AmbientVolume = Clamp((int)Math.Round(c.GetDouble("sound.volume.ambient", 1.0) * 100), 0, 100); s.SkipPipeline = c.GetBool("gfx.skippipeline", false); s.PythonScreenUpdateRate = c.Has("python.updatetime") ? Clamp(c.GetInt("python.updatetime", 200), 0, 10000) @@ -137,6 +151,10 @@ public sealed class SettingsSerializer s.AutoCloseStarter = c.GetBool("starter.autoclose", false); s.LargeThumbnails = c.GetBool("starter.largethumbnails", false); s.AutoExpandSceneryTree = c.GetBool("starter.expandtree", false); + s.CpuNotice = c.GetBool("starter.cpunotice", true); + s.Gpu = string.Equals(c.GetString("starter.gpu", "discrete"), "system", StringComparison.OrdinalIgnoreCase) + ? GpuPreference.System + : GpuPreference.Discrete; // An unrecognised value means "do not interfere" rather than the nearest // state: clamping upwards would silently force every consist ready. s.ReadyOverride = c.GetInt("starter.batterydefault", 0) switch @@ -151,18 +169,32 @@ 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); + + s.Extra.Clear(); + foreach (var key in SimulatorKeys.All) + if (c.Has(key.Key)) + s.Extra[key.Key] = c.GetRaw(key.Key) ?? string.Empty; } - private static readonly string[] ObsoleteKeys = + /// + /// Keys the simulator reads a set number of words after, whatever stands there: + /// one the file holds short takes the next keys in, so a save puts it right + /// even where nobody changed it. + /// + public static readonly string[] FixedLengthKeys = { "uart", "uarttune", "uartfeature" }; + + /// Keys written only when they say something, besides those of . + public static readonly string[] OptionalKeys = { "fpslimit", "gfx.angleplatform" }; + + /// Keys no simulator reads any more, taken out of the file on a save. + public static readonly string[] ObsoleteKeys = { "gfx.postfx.tonemapping", + "starter.developertools", }; public void Write(SimulatorSettings s, ConfigFile c) { - foreach (string key in ObsoleteKeys) - c.Remove(key); - c.Set("lang", string.IsNullOrWhiteSpace(s.LanguageCode) ? "en" : s.LanguageCode.Trim().ToLowerInvariant()); @@ -170,17 +202,25 @@ public sealed class SettingsSerializer c.SetBool("inactivepause", s.PauseWhenInactive); c.SetBool("pause", s.PauseOnStart); - double mx = s.CursorSensitivity * (s.InvertMouseHorizontal ? -1 : 1); + double magnitude = SensitivityStep(s.MouseScaleXMagnitude) == s.CursorSensitivity + ? s.MouseScaleXMagnitude + : s.CursorSensitivity; + double mx = magnitude * (s.InvertMouseHorizontal ? -1 : 1); double my = s.MouseScaleYMagnitude * (s.InvertMouseVertical ? -1 : 1); c.Set("mousescale", - $"{mx.ToString("0.###", CultureInfo.InvariantCulture)} " + - $"{my.ToString("0.###", CultureInfo.InvariantCulture)}"); + $"{mx.ToString("R", CultureInfo.InvariantCulture)} " + + $"{my.ToString("R", CultureInfo.InvariantCulture)}"); c.SetBool("input.gamepad", !s.IgnoreGamepad); c.SetInt("feedbackmode", s.FeedbackMode); c.SetInt("feedbackport", s.FeedbackPort); - if (s.UartEnabled && !string.IsNullOrWhiteSpace(s.UartPort)) c.Set("uart", s.UartPort); else c.Remove("uart"); - c.Set("uarttune", s.UartTune); + // The simulator reads three words after uart and eighteen numbers after + // uarttune, whatever stands there - with fewer, the next keys of the file + // go in their place. + if (s.UartEnabled && Uart.IsPortName(s.UartPort) && s.UartBaud > 0 && s.UartUpdateTime >= 0) + c.Set("uart", $"{s.UartPort.Trim()} {s.UartBaud.ToString(CultureInfo.InvariantCulture)} " + + s.UartUpdateTime.ToString("0.###", CultureInfo.InvariantCulture)); + if (Uart.NormalizeTune(s.UartTune) is { } tune) c.Set("uarttune", tune); c.SetBool("uartdebug", s.UartDebug); var sb = new StringBuilder(); if (s.UartMain) sb.Append("main|"); @@ -189,14 +229,16 @@ public sealed class SettingsSerializer if (s.UartLocal) sb.Append("local|"); if (s.UartRadioVolume) sb.Append("radiovolume|"); if (s.UartRadioChannel) sb.Append("radiochannel|"); - c.Set("uartfeature", sb.ToString()); + if (s.UartDynamicBrake) sb.Append("dynamicbrake|"); + // Left bare, the key would have the simulator take the next four words for + // its old yes/no form. + c.Set("uartfeature", sb.Length > 0 ? sb.ToString() : Uart.NoFeatures); c.SetBool("starter.exe.auto", s.SelectExeAutomatically); c.Set("starter.exe.path", s.ExecutablePath); c.SetBool("debugmode", s.DebugMode); c.SetBool("ai.trainman", s.VirtualShunting); c.SetBool("starter.logmissingvehicles", s.LogMissingVehicleFiles); - c.SetBool("starter.developertools", s.DeveloperTools); c.Set("gfxrenderer", SimulatorSettings.RenderEngines[Clamp(s.RenderEngine, 0, SimulatorSettings.RenderEngines.Length - 1)]); c.SetInt("width", s.Width); @@ -208,7 +250,7 @@ public sealed class SettingsSerializer c.SetInt("gfx.framebuffer.height", fbh); c.SetInt("maxtexturesize", s.MaxTextureSize); c.SetInt("maxcabtexturesize", s.MaxCabTextureSize); - c.SetInt("anisotropicfiltering", s.TextureFiltering); + c.SetDouble("anisotropicfiltering", s.TextureFiltering); c.SetInt("multisampling", s.Multisampling); c.SetInt("dynamiclights", s.DynamicLights); c.SetDouble("gfx.drawrange.factor.max", s.DrawRangeFactor); @@ -222,20 +264,20 @@ public sealed class SettingsSerializer c.SetBool("usevbo", s.UseVbo); c.SetBool("shadows", s.RenderShadows); c.SetBool("gfx.shadowmap.enabled", s.RenderShadows); - c.SetInt("gfx.reflections.framerate", s.ReflectionsFramerate); + c.Set("gfx.reflections.framerate", s.ReflectionsFramerate.ToString("0.######", CultureInfo.InvariantCulture)); c.Set("shadowtune", - $"{s.ShadowMapResolution} {s.ShadowProjectionRange} " + - $"{s.ShadowTuneExtra[0]} {s.ShadowTuneExtra[1]}"); + $"{s.ShadowMapResolution} {s.ShadowTuneUnused[0]} " + + $"{s.ShadowProjectionRange} {s.ShadowTuneUnused[1]}"); c.SetInt("gfx.shadows.cab.range", s.CabShadowsRange); c.SetInt("gfx.shadow.rank.cutoff", s.ShadowRankCutoff); c.SetInt("gfx.reflections.fidelity", s.ReflectionsFidelity); c.SetInt("fieldofview", s.FieldOfView); c.SetBool("python.enabled", s.PythonScreens); c.SetBool("python.threadedupload", s.PythonThreadedUpload); - if (s.FpsLimitEnabled) c.SetInt("fpslimit", s.FpsLimit); else c.Remove("fpslimit"); + if (s.FpsLimitEnabled) c.SetDouble("fpslimit", s.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"); + if (s.RenderAngleVulkan) c.Set("gfx.angleplatform", "vulkan"); c.SetInt("splinefidelity", s.SplineFidelity); c.SetBool("fullphysics", s.FullPhysics); @@ -274,6 +316,8 @@ public sealed class SettingsSerializer c.SetBool("starter.autoclose", s.AutoCloseStarter); c.SetBool("starter.largethumbnails", s.LargeThumbnails); c.SetBool("starter.expandtree", s.AutoExpandSceneryTree); + c.SetBool("starter.cpunotice", s.CpuNotice); + c.Set("starter.gpu", s.Gpu == GpuPreference.System ? "system" : "discrete"); c.SetInt("starter.batterydefault", (int)s.ReadyOverride); c.SetBool("starter.show.ai", s.ShowAiVehicles); c.SetBool("starter.drivableonly", s.DrivableOnly); @@ -281,23 +325,26 @@ public sealed class SettingsSerializer c.SetBool("starter.hide.archivalvehicles", s.HideArchivalVehicles); if (!string.IsNullOrWhiteSpace(s.LastScenery)) c.Set("starter.last.scenery", s.LastScenery); - else - c.Remove("starter.last.scenery"); c.SetBool("starter.window.maximized", s.WindowMaximized); + + // A key left bare would take the next line's key for its value, so an + // emptied one goes. + foreach (var key in SimulatorKeys.All) + if (s.Extra.TryGetValue(key.Key, out string? value) && !string.IsNullOrWhiteSpace(value)) + c.Set(key.Key, value.Trim()); } private static int Clamp(int v, int lo, int hi) => v < lo ? lo : (v > hi ? hi : v); + private static int SensitivityStep(double mouseScale) => + (int)Math.Clamp(Math.Round(Math.Abs(mouseScale)), 1, 5); + + private static double ParseDouble(string s, double fallback) => + double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double d) ? d : fallback; + private static int ParseInt(string s, int fallback) => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n) ? n : fallback; - private static int IndexOf(string[] values, string token, int fallback) - { - for (int i = 0; i < values.Length; i++) - if (string.Equals(values[i], token, StringComparison.OrdinalIgnoreCase)) - return i; - return fallback; - } private static int MapOldPyScreenPriority(string priority) => priority.ToLowerInvariant() switch diff --git a/StarterNG/Infrastructure/WineConfigLink.cs b/StarterNG/Infrastructure/WineConfigLink.cs new file mode 100644 index 0000000..23c3ff2 --- /dev/null +++ b/StarterNG/Infrastructure/WineConfigLink.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StarterNG.Application.Abstractions; +using StarterNG.Infrastructure.Settings; + +namespace StarterNG.Infrastructure; + +/// +/// Hands a Windows build started through Wine the settings the starter writes. +/// eu07.exe reads eu07.ini and the input files from %APPDATA%\MaSzyna, which Wine +/// keeps inside its prefix whatever APPDATA says - so that folder is made a link +/// to the starter's own, and both see one set of files. +/// +public sealed class WineConfigLink +{ + private const string VendorFolder = "MaSzyna"; + private const string BackupSuffix = ".before-starter"; + + private readonly IFileSystem _files; + private readonly IEnvironment _environment; + private readonly SettingsPaths _settingsPaths; + private readonly IDiagnosticsLog _log; + + public WineConfigLink(IFileSystem files, IEnvironment environment, SettingsPaths settingsPaths, + IDiagnosticsLog log) + { + _files = files; + _environment = environment; + _settingsPaths = settingsPaths; + _log = log; + } + + /// + /// Makes the prefix's MaSzyna folder a link to the folder the starter saves eu07.ini in. + /// A folder already there is kept beside it under another name, its files the + /// starter's folder lacks copied over; a link of the player's own to elsewhere + /// is left alone. Failing half way, it puts all that back, so the next attempt + /// starts from the same place. Whatever it does, or why not, goes to the log. + /// + public void Ensure() + { + try + { + if (RoamingDirectory() is not { } roaming) + { + _log.Log("Wine: no prefix with a user profile yet; eu07.exe reads its own eu07.ini this time"); + return; + } + + string link = Path.Combine(roaming, VendorFolder); + string target = Path.GetFullPath(_settingsPaths.UserConfigDirectory()); + if (_files.LinkTarget(link) is { } current) + { + if (!SamePath(Path.GetFullPath(current, roaming), target)) + _log.Log($"Wine: {link} links to {current}, not {target}; left as it is"); + return; + } + + if (_files.FileExists(link)) + { + _log.Log($"Wine: {link} is a file; settings not linked"); + return; + } + + _files.CreateDirectory(target); + string? backup = null; + var copied = new List(); + try + { + if (_files.DirectoryExists(link)) + { + string moved = FreeName(link + BackupSuffix); + _files.MoveDirectory(link, moved); + backup = moved; + _log.Log($"Wine: {link} moved to {backup}"); + CopyMissing(backup, target, copied); + } + + _files.CreateDirectoryLink(link, target); + _log.Log($"Wine: {link} now links to {target}"); + } + catch when (backup is not null) + { + PutBack(backup, link, copied); + throw; + } + } + catch (Exception ex) + { + _log.Log("Wine: linking the settings", ex); + } + } + + private void PutBack(string backup, string link, IEnumerable copied) + { + try + { + foreach (string copy in copied) + _files.DeleteFile(copy); + _files.MoveDirectory(backup, link); + _log.Log($"Wine: {link} put back as it was"); + } + catch (Exception ex) + { + _log.Log($"Wine: {link} could not be put back, its files stays at {backup}", ex); + } + } + + /// %APPDATA% inside the prefix the wine command uses, or null before one is made. + private string? RoamingDirectory() + { + string? prefix = _environment.GetVariable("WINEPREFIX"); + if (string.IsNullOrEmpty(prefix)) + { + string? home = _environment.GetVariable("HOME"); + if (string.IsNullOrEmpty(home)) + return null; + prefix = Path.Combine(home, ".wine"); + } + + if (UserProfile(Path.Combine(prefix, "drive_c", "users")) is not { } profile) + return null; + + // Wine 5 and later lay the profile out as Vista does; older ones as XP did. + string roaming = Path.Combine(profile, "AppData", "Roaming"); + if (_files.DirectoryExists(roaming)) + return roaming; + string applicationData = Path.Combine(profile, "Application Data"); + return _files.DirectoryExists(applicationData) ? applicationData : null; + } + + /// The profile named after the user, as Wine names it; or the only one there is. + private string? UserProfile(string users) + { + if (!_files.DirectoryExists(users)) + return null; + + foreach (string variable in new[] { "USER", "LOGNAME" }) + { + string? name = _environment.GetVariable(variable); + if (!string.IsNullOrEmpty(name) && _files.DirectoryExists(Path.Combine(users, name))) + return Path.Combine(users, name); + } + + var profiles = _files.GetDirectories(users) + .Where(path => !string.Equals(Path.GetFileName(path), "Public", StringComparison.OrdinalIgnoreCase)) + .ToList(); + return profiles.Count == 1 ? profiles[0] : null; + } + + // What eu07.exe kept in the prefix alone - eu07_input-uart.ini set up under + // Wine, say - so it still finds it; the starter's copy wins where both have one. + private void CopyMissing(string from, string to, List copied) + { + foreach (string file in _files.GetFiles(from)) + { + string copy = Path.Combine(to, Path.GetFileName(file)); + if (_files.FileExists(copy)) + continue; + _files.CopyFile(file, copy, overwrite: false); + copied.Add(copy); + _log.Log($"Wine: {Path.GetFileName(file)} copied to {to}"); + } + } + + private string FreeName(string path) + { + string name = path; + for (int n = 2; _files.DirectoryExists(name) || _files.FileExists(name) || _files.LinkTarget(name) != null; n++) + name = $"{path}-{n}"; + return name; + } + + private static bool SamePath(string a, string b) => + string.Equals(a.TrimEnd('/', '\\'), b.TrimEnd('/', '\\'), StringComparison.Ordinal); +}