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:
maj00r
2026-11-01 16:06:15 +01:00
parent 69aa075bfa
commit 3b21583b53
30 changed files with 4838 additions and 87 deletions

View 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());
}
}

View File

@@ -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()
{

View 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());
}
}

View 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);
}
}

View 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));
}
}

View File

@@ -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());
}
}

View 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);
}
}

View 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

View 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));
}
}

View 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);
}
}

View 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;
}

View File

@@ -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;

View File

@@ -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"),
},
};
}

View File

@@ -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);

View 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('.', '_')
};
}

View 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);
}
}

View 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;
}

View 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
};
}

View 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"),
};
}

View File

@@ -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" };
}

View 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;
}
}

View 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);
}

View 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;
}

View 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;
}

View 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>();
}
}
}

View 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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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

View 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);
}