mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-11-01 20:34:57 +01:00
Add graphics levels, screen choices and the rest of the simulator's keys
Six levels of detail climbing in even steps, High being where a player with no settings of their own starts. Five parts of the picture can also take a level of their own. Resolutions start from the monitors in the computer. uartfeature with nothing selected writes "no no no no"; uart and uarttune are written whole or not at all. On Linux the file's contents tell a Windows build, a native one and a script from a note that merely has the execute bit.
This commit is contained in:
100
StarterNG.Tests/DisplaysTests.cs
Normal file
100
StarterNG.Tests/DisplaysTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using StarterNG.Infrastructure;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class DisplaysTests
|
||||
{
|
||||
/// <summary>A 128-byte block with an 18-byte timing at 54, as a panel's base EDID has it.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>A DisplayID 2.0 block with one type VII timing (kHz, sizes less one).</summary>
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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<string>()).FileName);
|
||||
Assert.False(executables.IsWindowsBuild("eu07.exe"));
|
||||
Assert.Equal(nativeBuild, executables.LaunchCommand(nativeBuild, Array.Empty<string>()).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<string>()).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()
|
||||
{
|
||||
|
||||
190
StarterNG.Tests/GpuOffloadTests.cs
Normal file
190
StarterNG.Tests/GpuOffloadTests.cs
Normal file
@@ -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<string, string> For(GpuPreference preference, params GpuDevice[] gpus) =>
|
||||
Inheriting(Unset, preference, gpus);
|
||||
|
||||
private static IReadOnlyDictionary<string, string> Inheriting(Func<string, string?> inherited,
|
||||
GpuPreference preference,
|
||||
params GpuDevice[] gpus) =>
|
||||
GpuOffload.Target(gpus, preference, inherited) is { } target
|
||||
? GpuOffload.EnvironmentFor(target)
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
[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<GpuDevice>(), GpuPreference.Discrete, Unset));
|
||||
Assert.Equal(GpuNote.Unread, GpuOffload.NoteFor(Array.Empty<GpuDevice>(), 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());
|
||||
}
|
||||
}
|
||||
628
StarterNG.Tests/GraphicsPresetTests.cs
Normal file
628
StarterNG.Tests/GraphicsPresetTests.cs
Normal file
@@ -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<GraphicsPreset, double> 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<GraphicsPreset, int> fidelity in new Func<GraphicsPreset, int>[] { 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);
|
||||
}
|
||||
}
|
||||
119
StarterNG.Tests/KeyMapTests.cs
Normal file
119
StarterNG.Tests/KeyMapTests.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using StarterNG.Classes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class KeyMapTests
|
||||
{
|
||||
/// <summary>The names of the simulator's key table, keytonamemap in input/keyboardinput.cpp.</summary>
|
||||
private static readonly HashSet<string> 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<string>();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
103
StarterNG.Tests/ResolutionChoicesTests.cs
Normal file
103
StarterNG.Tests/ResolutionChoicesTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
using StarterNG.Domain.Settings;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class ResolutionChoicesTests
|
||||
{
|
||||
private static List<string> 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<ScreenSize>(), 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);
|
||||
}
|
||||
}
|
||||
128
StarterNG.Tests/SettingReachTests.cs
Normal file
128
StarterNG.Tests/SettingReachTests.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
42
StarterNG.Tests/StepsTests.cs
Normal file
42
StarterNG.Tests/StepsTests.cs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
209
StarterNG.Tests/WineConfigLinkTests.cs
Normal file
209
StarterNG.Tests/WineConfigLinkTests.cs
Normal file
@@ -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<InMemoryFileSystem>? 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);
|
||||
}
|
||||
}
|
||||
56
StarterNG/Application/GraphicsGroupLevels.cs
Normal file
56
StarterNG/Application/GraphicsGroupLevels.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StarterNG.Domain.Settings;
|
||||
|
||||
namespace StarterNG.Application;
|
||||
|
||||
/// <summary>Levels of detail part by part: what a level holds for one <see cref="GraphicsGroup"/>, the rest as it is.</summary>
|
||||
public sealed class GraphicsGroupLevels
|
||||
{
|
||||
private readonly SettingsStore _store;
|
||||
|
||||
public GraphicsGroupLevels(SettingsStore store) => _store = store;
|
||||
|
||||
/// <summary>
|
||||
/// The level each group is at in <paramref name="settings"/>, 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.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<GraphicsGroup, GraphicsPreset?> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy of <paramref name="settings"/> with the group at <paramref name="level"/>. What only
|
||||
/// an end of the scale sets goes back to the simulator's own on leaving that end.
|
||||
/// </summary>
|
||||
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<string, string> RenderAt(GraphicsPreset level, SimulatorSettings settings)
|
||||
{
|
||||
var copy = settings.Copy();
|
||||
level.ApplyTo(copy);
|
||||
return _store.Render(copy);
|
||||
}
|
||||
|
||||
private static bool Same(GraphicsGroup group, IReadOnlyDictionary<string, string> one, IReadOnlyDictionary<string, string> 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<string, string> keys, string key) =>
|
||||
keys.TryGetValue(key, out string? value) ? value : SimulatorKeys.All.FirstOrDefault(known => known.Key == key)?.Default;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
/// <summary>
|
||||
/// Keys the screen shows otherwise than the settings hold them - a value its
|
||||
/// control cannot show - by key, in the file's terms.
|
||||
/// </summary>
|
||||
IReadOnlyDictionary<string, ShownValue> ShownUnlikeSettings => new Dictionary<string, ShownValue>();
|
||||
}
|
||||
|
||||
/// <summary>A key as the screen shows it, and as the settings held it when it was put there; null for absent.</summary>
|
||||
public sealed record ShownValue(string? Shown, string? Held);
|
||||
|
||||
/// <summary>A key a save changes: its value in the file before and after; null where it has none.</summary>
|
||||
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<string, string> _synced = new Dictionary<string, string>();
|
||||
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The last read failed, so <see cref="Settings"/> holds defaults, not the file's.
|
||||
/// Nothing is saved until a read works: that would put them in its place.
|
||||
/// </summary>
|
||||
public bool LoadFailed { get; private set; }
|
||||
|
||||
/// <summary>The last save did not reach the file; the log says why.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>A player with no settings of their own yet starts at High, in the file at once so the simulator goes by it too.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Every key as <paramref name="settings"/> would put it, in the order they are written.</summary>
|
||||
public IReadOnlyDictionary<string, string> Render(SimulatorSettings settings)
|
||||
{
|
||||
var config = new ConfigFile();
|
||||
_serializer.Write(settings, config);
|
||||
var keys = new OrderedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (key, value) in config.Entries())
|
||||
keys[key] = value;
|
||||
return keys;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What saving <paramref name="settings"/> 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.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IniChange> Changes(SimulatorSettings settings)
|
||||
{
|
||||
var now = Render(settings);
|
||||
var shown = _capture?.ShownUnlikeSettings ?? new Dictionary<string, ShownValue>();
|
||||
var changes = new List<IniChange>();
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The keys <paramref name="shown"/> puts otherwise than the settings hold them,
|
||||
/// with both values, in the file's terms.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, ShownValue> ShownUnlike(SimulatorSettings shown)
|
||||
{
|
||||
var held = Render(Settings);
|
||||
var now = Render(shown);
|
||||
var unlike = new Dictionary<string, ShownValue>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A copy of <paramref name="settings"/> with <paramref name="keys"/> back at
|
||||
/// what the simulator goes by when eu07.ini does not set them.
|
||||
/// </summary>
|
||||
public SimulatorSettings WithDefaults(SimulatorSettings settings, IReadOnlyCollection<string> keys)
|
||||
{
|
||||
var defaults = new SimulatorSettings();
|
||||
_serializer.Read(defaults, new ConfigFile());
|
||||
return WithKeysOf(settings, defaults, keys);
|
||||
}
|
||||
|
||||
/// <summary>A copy of <paramref name="settings"/> with <paramref name="keys"/> as <paramref name="source"/> has them.</summary>
|
||||
public SimulatorSettings WithKeysOf(SimulatorSettings settings, SimulatorSettings source, IReadOnlyCollection<string> 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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the simulator does with a key combination on its own.</summary>
|
||||
/// <param name="TextKey">What that is, as a translation key.</param>
|
||||
/// <param name="Blocks">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public readonly record struct SimulatorKeyUse(string TextKey, bool Blocks);
|
||||
|
||||
public static class KeyMap
|
||||
{
|
||||
public const string NumpadPrefix = "num_";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>A key combination as it reads on screen; a dash for no key, whatever the modifiers.</summary>
|
||||
public static string ComboText(string? token, bool shift, bool ctrl)
|
||||
{
|
||||
if (IsNoKey(token))
|
||||
return "—";
|
||||
var parts = new List<string>();
|
||||
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"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <summary>The combination in a form to compare, however the key is spelled.</summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <paramref name="translated"/> one for the starter's language, else the
|
||||
/// bundled one with Polish letters.
|
||||
/// </summary>
|
||||
public string DescriptionOf(KeyBinding binding)
|
||||
public string DescriptionOf(KeyBinding binding, Func<string, string>? 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));
|
||||
|
||||
/// <summary>
|
||||
/// Puts <paramref name="binding"/> 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The <see cref="KeyBinding.ComboKey"/> of each combination more than one binding is on.</summary>
|
||||
public HashSet<string> ConflictingCombos()
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var dup = new HashSet<string>(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);
|
||||
|
||||
125
StarterNG/Domain/Settings/GpuOffload.cs
Normal file
125
StarterNG/Domain/Settings/GpuOffload.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StarterNG.Domain.Settings;
|
||||
|
||||
/// <summary>Which graphics card the simulator is started on, where there are two.</summary>
|
||||
public enum GpuPreference
|
||||
{
|
||||
/// <summary>The card that is not driving the boot display - the discrete one on a laptop.</summary>
|
||||
Discrete,
|
||||
/// <summary>Whatever the system gives a program, as a rule the integrated card.</summary>
|
||||
System
|
||||
}
|
||||
|
||||
/// <summary>What there is to say about the cards found and the choice made.</summary>
|
||||
public enum GpuNote
|
||||
{
|
||||
/// <summary>No card could be read.</summary>
|
||||
Unread,
|
||||
/// <summary>One card leaves nothing to choose.</summary>
|
||||
OneCard,
|
||||
/// <summary>The system decides, by choice.</summary>
|
||||
LeftToSystem,
|
||||
/// <summary>The player chose, by what the starter was started with.</summary>
|
||||
PlayerChose,
|
||||
/// <summary>The simulator would be moved to <see cref="GpuOffload.Target"/>.</summary>
|
||||
Moved,
|
||||
/// <summary>There is no card to move the simulator to.</summary>
|
||||
NoTarget
|
||||
}
|
||||
|
||||
/// <summary>A graphics card as Linux lists it under /sys/class/drm.</summary>
|
||||
/// <param name="Slot">PCI address, as 0000:01:00.0.</param>
|
||||
/// <param name="PciId">Vendor and device, as 10DE:2D19.</param>
|
||||
/// <param name="Driver">The kernel driver: nvidia, amdgpu, i915...</param>
|
||||
/// <param name="BootVga">The card the firmware started the display on.</param>
|
||||
/// <param name="InternalPanel">The card has a laptop's built-in screen among its outputs (eDP, LVDS, DSI).</param>
|
||||
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})";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> MesaDrivers = new(StringComparer.Ordinal)
|
||||
{
|
||||
"amdgpu", "radeon", "i915", "xe", "nouveau"
|
||||
};
|
||||
|
||||
/// <summary>The card the simulator would be moved to, or null when it is left alone.</summary>
|
||||
public static GpuDevice? Target(IReadOnlyList<GpuDevice> gpus, GpuPreference preference,
|
||||
Func<string, string?> 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));
|
||||
}
|
||||
|
||||
/// <summary>The variables that say the player chose a card, of those set.</summary>
|
||||
public static IReadOnlyList<string> PlayerChoices(Func<string, string?> inherited) =>
|
||||
SelectionVariables.Where(name => !string.IsNullOrEmpty(inherited(name))).ToArray();
|
||||
|
||||
public static GpuNote NoteFor(IReadOnlyList<GpuDevice> gpus, GpuPreference preference,
|
||||
Func<string, string?> 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);
|
||||
|
||||
/// <summary>The variables that put a program on <paramref name="target"/>.</summary>
|
||||
public static IReadOnlyDictionary<string, string> EnvironmentFor(GpuDevice target) =>
|
||||
target.Driver == "nvidia"
|
||||
? new Dictionary<string, string>
|
||||
{
|
||||
["__NV_PRIME_RENDER_OFFLOAD"] = "1",
|
||||
["__GLX_VENDOR_LIBRARY_NAME"] = "nvidia",
|
||||
["__VK_LAYER_NV_optimus"] = "NVIDIA_only",
|
||||
["VK_LOADER_DRIVERS_SELECT"] = "*nvidia*"
|
||||
}
|
||||
: new Dictionary<string, string>
|
||||
{
|
||||
["DRI_PRIME"] = "pci-" + target.Slot.Replace(':', '_').Replace('.', '_')
|
||||
};
|
||||
}
|
||||
396
StarterNG/Domain/Settings/GraphicsPreset.cs
Normal file
396
StarterNG/Domain/Settings/GraphicsPreset.cs
Normal file
@@ -0,0 +1,396 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace StarterNG.Domain.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="PythonUpdateMs">python.updatetime, one of the steps of its list.</param>
|
||||
public sealed record GraphicsExtras(
|
||||
bool MotionBlur,
|
||||
bool ChromaticAberration,
|
||||
bool Smoke,
|
||||
int PythonUpdateMs)
|
||||
{
|
||||
/// <summary>The eu07.ini keys these values live in.</summary>
|
||||
public static readonly IReadOnlyList<string> Keys = new[]
|
||||
{
|
||||
"gfx.postfx.motionblur.enabled", "gfx.postfx.chromaticaberration.enabled",
|
||||
"gfx.smoke", "python.updatetime"
|
||||
};
|
||||
|
||||
/// <summary>The simulator's own, for leaving a level chosen before this run: what was there earlier is gone.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="ShadowAngleLimit">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).</param>
|
||||
/// <param name="TargetFps">targetfps: above 0 the simulator moves the distance factor by itself.</param>
|
||||
/// <param name="ScreenWindowFps">python.fpslimit: read only by the screens' separate windows
|
||||
/// (pythonscreenviewer.cpp), it does nothing without them.</param>
|
||||
/// <param name="TextureRelease">gfx.textures.releasedistance: above 0 textures of far vehicles leave the card.</param>
|
||||
/// <param name="CompressTextures">compresstex: on saves card memory at a cost to the picture.</param>
|
||||
/// <param name="SkipDecor">starter.ignoreirrelevant: on leaves decorative consists out of the scenario.</param>
|
||||
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";
|
||||
|
||||
/// <summary>Those of its keys shown as rows of <see cref="SimulatorKeys"/>.</summary>
|
||||
public static readonly IReadOnlyList<string> RowKeys = new[]
|
||||
{
|
||||
TargetFpsKey, ScreenWindowFpsKey, TextureReleaseKey, SkyKey, CabKey, AirCouplersKey, SmoothTractionKey
|
||||
};
|
||||
|
||||
/// <summary>The eu07.ini keys these values live in.</summary>
|
||||
public static readonly IReadOnlyList<string> Keys = RowKeys.Concat(new[]
|
||||
{
|
||||
"gfx.shadow.angle.min", "python.enabled", "python.mipmaps", "compresstex", "starter.ignoreirrelevant"
|
||||
}).ToList();
|
||||
|
||||
/// <summary>What they are with nothing set, as <see cref="GraphicsExtras.None"/>.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A level of detail set in one go: what weighs on the graphics card. Every scale climbs in
|
||||
/// even steps from <see cref="Minimal"/> to <see cref="Ultra"/>, 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 <see cref="Extras"/>.
|
||||
/// </summary>
|
||||
/// <param name="NameKey">Its name's translation key.</param>
|
||||
/// <param name="ReflectionsRate">gfx.reflections.framerate, in updates a second.</param>
|
||||
/// <param name="DrawRange">basedrawrange, in metres. Below 2000 it changes nothing on screen: the renderer
|
||||
/// keeps at least that for signals and lights (opengl33renderer.cpp).</param>
|
||||
/// <param name="DistanceFactor">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.</param>
|
||||
/// <param name="BufferScale">starter.bufferscale, in percent. Above the screen over High: on a 4K one
|
||||
/// Ultra is a picture of 8K, multisampled on top.</param>
|
||||
/// <param name="DrawRangeFactor">gfx.drawrange.factor.max: how far the regulator may stretch the draw range.</param>
|
||||
/// <param name="CabShadowsRange">gfx.shadows.cab.range, in metres, read only by the legacy renderer.</param>
|
||||
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";
|
||||
|
||||
/// <summary>Those of its keys shown as rows of <see cref="SimulatorKeys"/>.</summary>
|
||||
public static readonly IReadOnlyList<string> RowKeys = new[]
|
||||
{
|
||||
DrawRangeKey, DistanceFactorKey, SleeperKey, ReflectedInstancesKey, ReflectedVehiclesKey
|
||||
};
|
||||
|
||||
/// <summary>The eu07.ini keys a level sets.</summary>
|
||||
public static readonly IReadOnlyList<string> 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"
|
||||
};
|
||||
|
||||
/// <summary>The last resort: no shadows, no reflections, no effects, no smoke, a softer picture.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>For a strong card. A map of 4096 doubles the cascades, so the range here reaches 800 m.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="VeryHigh"/>.
|
||||
/// </summary>
|
||||
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<GraphicsPreset> 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);
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="s"/> is at this level throughout.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>The level <paramref name="s"/> is at throughout, if any.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="Keys">The eu07.ini keys it sets.</param>
|
||||
public sealed record GraphicsGroup(IReadOnlyList<string> 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<GraphicsGroup> All = new[] { DrawRange, Textures, Effects, Shadows, Reflections };
|
||||
}
|
||||
|
||||
/// <summary>What applying a level touched.</summary>
|
||||
/// <param name="RowKeys">The keys of <see cref="SimulatorKeys"/> rows to show its values in, in that order.</param>
|
||||
/// <param name="ChosenKeys">The eu07.ini keys it chose as a whole.</param>
|
||||
public sealed record PresetApplied(IReadOnlyList<string> RowKeys, IReadOnlyList<string> ChosenKeys);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class GraphicsPresetMemory
|
||||
{
|
||||
private GraphicsExtras? _beforeExtras;
|
||||
|
||||
private GraphicsTop? _beforeTop;
|
||||
|
||||
/// <summary>Puts <paramref name="preset"/> in <paramref name="settings"/>, with what an end of the scale left behind.</summary>
|
||||
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<string>(GraphicsPreset.RowKeys);
|
||||
var chosen = new List<string>(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);
|
||||
}
|
||||
}
|
||||
56
StarterNG/Domain/Settings/ResolutionChoices.cs
Normal file
56
StarterNG/Domain/Settings/ResolutionChoices.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace StarterNG.Domain.Settings;
|
||||
|
||||
/// <summary>A size in pixels, as width and height in eu07.ini take it.</summary>
|
||||
public readonly record struct ScreenSize(int Width, int Height)
|
||||
{
|
||||
public override string ToString() => $"{Width}x{Height}";
|
||||
}
|
||||
|
||||
/// <summary>A size to offer for the simulator's window.</summary>
|
||||
/// <param name="IsMonitor">A monitor in the computer has exactly this size.</param>
|
||||
public readonly record struct ResolutionChoice(ScreenSize Size, bool IsMonitor);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)
|
||||
};
|
||||
|
||||
/// <param name="monitors">The monitors, the primary one first; empty where they cannot be told.</param>
|
||||
/// <param name="current">The size asked for now, kept on the list whatever it is.</param>
|
||||
public static IReadOnlyList<ResolutionChoice> For(IReadOnlyCollection<ScreenSize> 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;
|
||||
}
|
||||
122
StarterNG/Domain/Settings/SettingReach.cs
Normal file
122
StarterNG/Domain/Settings/SettingReach.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StarterNG.Domain.Settings;
|
||||
|
||||
/// <summary>Why the simulator about to be started would pass a key over.</summary>
|
||||
public enum IdleReason
|
||||
{
|
||||
None,
|
||||
WindowsBuildOnly,
|
||||
LegacyRendererOnly,
|
||||
/// <summary>Read by the full OpenGL renderer alone.</summary>
|
||||
ModernRendererOnly,
|
||||
/// <summary>Read by the OpenGL renderers, which the experimental one is not.</summary>
|
||||
OpenGlRenderersOnly,
|
||||
NeedsNetworkServer,
|
||||
/// <summary>The draw range is only stretched by the frame rate regulator, which is off.</summary>
|
||||
NeedsFrameRateTarget,
|
||||
/// <summary>fullscreenwindowed takes the monitor's own mode instead.</summary>
|
||||
MonitorModeInstead
|
||||
}
|
||||
|
||||
/// <summary>What about the simulator to be started decides which keys it heeds.</summary>
|
||||
/// <param name="WindowsBuild">The executable is a Windows build, under Wine or not.</param>
|
||||
/// <param name="Renderer">The gfxrenderer word, as <see cref="SimulatorSettings.RenderEngines"/> has it.</param>
|
||||
/// <param name="NetworkServer">eu07.ini starts a network.server.</param>
|
||||
/// <param name="TargetFps">targetfps as eu07.ini sets it, or null where it does not.</param>
|
||||
/// <param name="FpsAverage">fpsaverage, the older regulator's target; 0 for none.</param>
|
||||
/// <param name="FullscreenWindowed">fullscreenwindowed is on.</param>
|
||||
public sealed record SimulatorTarget(bool WindowsBuild, string Renderer, bool NetworkServer,
|
||||
double? TargetFps = null, double FpsAverage = 0,
|
||||
bool FullscreenWindowed = false);
|
||||
|
||||
/// <summary>The renderers a gfxrenderer word picks, as application.cpp tells them apart.</summary>
|
||||
public enum RendererKind { Modern, Legacy, Experimental }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string> WindowsBuildOnly = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"feedbackmode", "feedbackport", "showsystemconsole", "gfx.angleplatform"
|
||||
};
|
||||
|
||||
// Read by openglrenderer.cpp alone; the modern path forces VBOs on.
|
||||
private static readonly HashSet<string> 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<string> 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<string> 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<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static bool DrawRangeRegulated(SimulatorTarget target) =>
|
||||
target.FpsAverage != 0 || (target.TargetFps ?? 0) != 0;
|
||||
|
||||
/// <summary>"full" and "default" are the modern renderer, "experimental" its own; every other word the legacy one.</summary>
|
||||
public static RendererKind KindOf(string renderer) => renderer.ToLowerInvariant() switch
|
||||
{
|
||||
"full" or "default" => RendererKind.Modern,
|
||||
"experimental" => RendererKind.Experimental,
|
||||
_ => RendererKind.Legacy
|
||||
};
|
||||
}
|
||||
128
StarterNG/Domain/Settings/SimulatorKeys.cs
Normal file
128
StarterNG/Domain/Settings/SimulatorKeys.cs
Normal file
@@ -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 }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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)
|
||||
{
|
||||
/// <summary>The label's translation key; the explanation's adds "Desc".</summary>
|
||||
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";
|
||||
|
||||
/// <summary>The key's value in <paramref name="s"/>, in the file's words: what is set, or else the default.</summary>
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Shake has a factor per axis; the row moves them together, so a number is
|
||||
/// written as many times as the key takes it.
|
||||
/// </summary>
|
||||
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<SimulatorKey> 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"),
|
||||
};
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
/// <summary>eu07.ini starts a network.server, which the starter leaves to the file.</summary>
|
||||
public bool NetworkServer { get; internal set; }
|
||||
|
||||
/// <summary>fpsaverage: the older regulator's frame rate target, which the starter leaves as it is.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>The simulator's dynamicbrake feature, which has no box in the starter: kept as the file has it.</summary>
|
||||
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;
|
||||
/// <summary>Updates of the reflections a second; the simulator's own is one in five minutes.</summary>
|
||||
public double ReflectionsFramerate = 1 / 300.0;
|
||||
public int ShadowMapResolution = 2048;
|
||||
/// <summary>The third number of shadowtune; the second and fourth go unread.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Warn at start when the processor cannot run the official simulator builds.</summary>
|
||||
public bool CpuNotice = true;
|
||||
|
||||
/// <summary>The card a native Linux simulator is started on, on a machine with two.</summary>
|
||||
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" };
|
||||
/// <summary>
|
||||
/// The <see cref="SimulatorKeys"/> as eu07.ini carries them, by key. One the
|
||||
/// file does not set is absent, and stays out of the file until changed.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Extra { get; private set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static readonly int[] AnisotropySteps = { 1, 2, 4, 8, 16 };
|
||||
/// <summary>A copy to try changes on, apart from these settings.</summary>
|
||||
public SimulatorSettings Copy()
|
||||
{
|
||||
var copy = (SimulatorSettings)MemberwiseClone();
|
||||
copy.Extra = new Dictionary<string, string>(Extra, StringComparer.OrdinalIgnoreCase);
|
||||
copy.ShadowTuneUnused = (string[])ShadowTuneUnused.Clone();
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static readonly string[] RenderEngines = { "full", "legacy", "simple", "experimental" };
|
||||
|
||||
/// <summary>Where <paramref name="word"/> stands among <see cref="RenderEngines"/>, as the simulator reads it.</summary>
|
||||
public static int RenderEngineOf(string word) => word.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"full" or "default" => 0,
|
||||
"simple" => 2,
|
||||
"experimental" => 3,
|
||||
_ => 1
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The least shadow range the simulator keeps for a map size: a map above
|
||||
/// 2048 px stretches over proportionally more ground.
|
||||
/// </summary>
|
||||
public static int ShadowRangeFloor(int mapSize) => mapSize <= 2048 ? 75 : 75 * mapSize / 2048;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The file's own first mousescale number. The slider takes whole steps: this
|
||||
/// stands for as long as <see cref="CursorSensitivity"/> is the step nearest it.
|
||||
/// </summary>
|
||||
internal double MouseScaleXMagnitude { get; set; } = 2;
|
||||
|
||||
internal double MouseScaleYMagnitude { get; set; } = 0.2;
|
||||
|
||||
internal string[] ShadowTuneExtra { get; set; } = { "400", "300" };
|
||||
/// <summary>The numbers of shadowtune the simulator skips, kept as the file has them.</summary>
|
||||
internal string[] ShadowTuneUnused { get; set; } = { "250", "300" };
|
||||
}
|
||||
|
||||
59
StarterNG/Domain/Settings/Steps.cs
Normal file
59
StarterNG/Domain/Settings/Steps.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StarterNG.Domain.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Values a setting is chosen from, for a number the simulator takes any value
|
||||
/// of but only some of which are worth offering.
|
||||
/// </summary>
|
||||
public static class Steps
|
||||
{
|
||||
/// <summary>fpslimit: none, then from a laptop on battery up to the fastest monitors.</summary>
|
||||
public static readonly double[] FrameRateLimits =
|
||||
{ 0, 20, 25, 30, 40, 45, 50, 60, 72, 75, 90, 100, 120, 144, 165, 180, 200, 240, 360 };
|
||||
|
||||
/// <summary>
|
||||
/// gfx.reflections.framerate, in updates a second: from the simulator's own
|
||||
/// once in five minutes up to every frame.
|
||||
/// </summary>
|
||||
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 };
|
||||
|
||||
/// <summary>gfx.drawrange.factor.max: how far the regulator may stretch the draw range.</summary>
|
||||
public static readonly double[] DrawRangeFactors = { 1, 1.5, 2, 2.5, 3 };
|
||||
|
||||
/// <summary>anisotropicfiltering: 16 is the most any card offers.</summary>
|
||||
public static readonly double[] Anisotropy = { 1, 2, 4, 8, 16 };
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static readonly double[] MultisamplingLevels = { 0, 1, 2, 3 };
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="steps"/> with <paramref name="value"/> among them: one
|
||||
/// eu07.ini has that is none of the steps stays what it is.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<double> With(IReadOnlyList<double> steps, double value)
|
||||
{
|
||||
if (IndexOf(steps, value) >= 0 || !double.IsFinite(value))
|
||||
return steps;
|
||||
|
||||
var list = new List<double>(steps);
|
||||
int at = list.FindIndex(step => step > value);
|
||||
list.Insert(at < 0 ? list.Count : at, value);
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>The step that is <paramref name="value"/>, give or take its rounding in the file.</summary>
|
||||
public static int IndexOf(IReadOnlyList<double> 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;
|
||||
}
|
||||
}
|
||||
115
StarterNG/Domain/Settings/Uart.cs
Normal file
115
StarterNG/Domain/Settings/Uart.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace StarterNG.Domain.Settings;
|
||||
|
||||
/// <summary>What the simulator takes after its uart keys.</summary>
|
||||
public static class Uart
|
||||
{
|
||||
/// <summary>The speeds the simulator offers in its own UART panel.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// The calibration as the simulator reads it - eighteen numbers - or null.
|
||||
/// With fewer, it takes the next keys of the file in their place.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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 _);
|
||||
|
||||
/// <summary>The simulator reads the port as one word.</summary>
|
||||
public static bool IsPortName(string? port) =>
|
||||
!string.IsNullOrWhiteSpace(port) && !port.Trim().Any(char.IsWhiteSpace);
|
||||
|
||||
/// <summary>The interval as its box can hold it, whatever the file says.</summary>
|
||||
public static decimal ShownUpdateTime(double seconds) =>
|
||||
double.IsNaN(seconds) ? (decimal)DefaultUpdateTime : (decimal)Math.Clamp(seconds, 0, MaxShownUpdateTime);
|
||||
|
||||
public const double MaxShownUpdateTime = 10;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static ISet<string> FeaturesOn(string? value)
|
||||
{
|
||||
var words = (value ?? "").Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
var on = new HashSet<string>(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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="NoFeatures"/> is for.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
16
StarterNG/Infrastructure/CpuFeatures.cs
Normal file
16
StarterNG/Infrastructure/CpuFeatures.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Runtime.Intrinsics.X86;
|
||||
|
||||
namespace StarterNG.Infrastructure;
|
||||
|
||||
/// <summary>What the processor offers against what the simulator builds assume.</summary>
|
||||
public static class CpuFeatures
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static bool LacksAvx2 =>
|
||||
RuntimeInformation.ProcessArchitecture is Architecture.X64 or Architecture.X86 && !Avx2.IsSupported;
|
||||
}
|
||||
188
StarterNG/Infrastructure/Displays.cs
Normal file
188
StarterNG/Infrastructure/Displays.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>A screen, and the fastest it refreshes.</summary>
|
||||
/// <param name="Name">The connector on Linux (eDP-1, HDMI-A-1); empty for the primary screen on Windows.</param>
|
||||
public sealed record Display(string Name, int RefreshRate);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Every screen whose refresh rate could be read; empty where none could.</summary>
|
||||
public IReadOnlyList<Display> List()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_environment.IsLinux)
|
||||
return FromDrm();
|
||||
if (_environment.IsWindows && OperatingSystem.IsWindows())
|
||||
return PrimaryOnWindows() is { } rate ? new[] { new Display(string.Empty, rate) } : Array.Empty<Display>();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A screen we cannot read leaves the limit to the player, as before.
|
||||
}
|
||||
|
||||
return Array.Empty<Display>();
|
||||
}
|
||||
|
||||
private IReadOnlyList<Display> FromDrm()
|
||||
{
|
||||
var displays = new List<Display>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The timings an EDID lists, as far as a refresh rate goes.</summary>
|
||||
public static class Edid
|
||||
{
|
||||
/// <summary>The fastest refresh rate among the detailed timings, rounded; 0 for none.</summary>
|
||||
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;
|
||||
}
|
||||
74
StarterNG/Infrastructure/LinuxGpus.cs
Normal file
74
StarterNG/Infrastructure/LinuxGpus.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Every card with a PCI address and a driver; empty off Linux, or when /sys cannot be read.</summary>
|
||||
public IReadOnlyList<GpuDevice> List()
|
||||
{
|
||||
if (!_environment.IsLinux)
|
||||
return Array.Empty<GpuDevice>();
|
||||
|
||||
try
|
||||
{
|
||||
var gpus = new List<GpuDevice>();
|
||||
var entries = _files.GetDirectories(DrmClass).Select(Path.GetFileName).OfType<string>().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<GpuDevice>();
|
||||
}
|
||||
}
|
||||
}
|
||||
47
StarterNG/Infrastructure/SerialPorts.cs
Normal file
47
StarterNG/Infrastructure/SerialPorts.cs
Normal file
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
|
||||
namespace StarterNG.Infrastructure;
|
||||
|
||||
/// <summary>The serial ports present now, named the way the simulator's uart key wants them.</summary>
|
||||
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<string> 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<string>();
|
||||
}
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
private static IReadOnlyList<string> FromRegistry()
|
||||
{
|
||||
using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"HARDWARE\DEVICEMAP\SERIALCOMM");
|
||||
if (key is null)
|
||||
return Array.Empty<string>();
|
||||
var ports = new List<string>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
/// <summary>Whether <paramref name="path"/> is a Windows build the starter hands to Wine.</summary>
|
||||
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) =>
|
||||
/// <summary>A path as <see cref="Resolve"/> gives it, which may be relative to the installation.</summary>
|
||||
public string FullPath(string path) =>
|
||||
Path.IsPathRooted(path) ? path : Path.Combine(_paths.Root, path);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool IsWindowsBuild(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Format(FullPath(path)) switch
|
||||
{
|
||||
BinaryFormat.PortableExecutable => true,
|
||||
BinaryFormat.Elf => false,
|
||||
_ => _environment.IsWindows
|
||||
};
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return _environment.IsWindows;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string>(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 =
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static readonly string[] FixedLengthKeys = { "uart", "uarttune", "uartfeature" };
|
||||
|
||||
/// <summary>Keys written only when they say something, besides those of <see cref="SimulatorKeys"/>.</summary>
|
||||
public static readonly string[] OptionalKeys = { "fpslimit", "gfx.angleplatform" };
|
||||
|
||||
/// <summary>Keys no simulator reads any more, taken out of the file on a save.</summary>
|
||||
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
|
||||
|
||||
178
StarterNG/Infrastructure/WineConfigLink.cs
Normal file
178
StarterNG/Infrastructure/WineConfigLink.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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<string> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>%APPDATA% inside the prefix the wine command uses, or null before one is made.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>The profile named after the user, as Wine names it; or the only one there is.</summary>
|
||||
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<string> 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);
|
||||
}
|
||||
Reference in New Issue
Block a user