diff --git a/StarterNG.Tests/SimulatorAftermathTests.cs b/StarterNG.Tests/SimulatorAftermathTests.cs index ecbdf93..1c3f31b 100644 --- a/StarterNG.Tests/SimulatorAftermathTests.cs +++ b/StarterNG.Tests/SimulatorAftermathTests.cs @@ -32,8 +32,39 @@ public class SimulatorAftermathTests var installation = new TestInstallation(new InMemoryFileSystem() .WithFile(TestInstallation.At("log.txt"), "first\r\nsecond\r\n\r\nthird\r\n")); - var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log).SimulatorLogTail(2); + var started = installation.Files.LastWriteTimeUtc.AddMinutes(-1); + + var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log) + .SimulatorLogTail(2, started); Assert.Equal(new[] { "second", "third" }, tail); } + + [Fact] + public void A_log_not_written_since_the_start_says_nothing_of_this_run() + { + // Dead before it opened its log: what is there is the session before. + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("log.txt"), "yesterday's error\r\n")); + var started = installation.Files.LastWriteTimeUtc.AddMinutes(10); + + var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log) + .SimulatorLogTail(2, started); + + Assert.Empty(tail); + } + + [Fact] + public void A_log_written_a_moment_before_the_start_was_taken_counts() + { + // The start is taken just after the launch, as for the crash dumps. + var installation = new TestInstallation(new InMemoryFileSystem() + .WithFile(TestInstallation.At("log.txt"), "opening\r\n")); + var started = installation.Files.LastWriteTimeUtc.AddSeconds(3); + + var tail = new SimulatorAftermath(installation.Files, installation.Paths, installation.Log) + .SimulatorLogTail(2, started); + + Assert.Equal(new[] { "opening" }, tail); + } } diff --git a/StarterNG.Tests/SimulatorExitTests.cs b/StarterNG.Tests/SimulatorExitTests.cs index ab905aa..7cb0f68 100644 --- a/StarterNG.Tests/SimulatorExitTests.cs +++ b/StarterNG.Tests/SimulatorExitTests.cs @@ -55,11 +55,28 @@ public class SimulatorExitTests ? ("cmd", new[] { "/c", "echo boom 1>&2 & exit 3" }) : ("/bin/sh", new[] { "-c", "echo boom >&2; exit 3" }); - var process = launcher.Start(shell, script, null, captureErrors: true, out string? error); + var process = launcher.Start(shell, script, null, new Dictionary(), captureErrors: true, + out string? error); Assert.Null(error); await process!.WaitForExitAsync(); Assert.Equal(3, process.ExitCode); Assert.Equal("boom", Assert.Single(process.ErrorTail).Trim()); } + + [Fact] + public async Task A_real_process_gets_the_environment_it_was_given() + { + var launcher = new SystemProcessLauncher(new TestInstallation().Log); + var (shell, script) = OperatingSystem.IsWindows() + ? ("cmd", new[] { "/c", "echo %__NV_PRIME_RENDER_OFFLOAD% 1>&2" }) + : ("/bin/sh", new[] { "-c", "echo $__NV_PRIME_RENDER_OFFLOAD >&2" }); + + var process = launcher.Start(shell, script, null, + new Dictionary { ["__NV_PRIME_RENDER_OFFLOAD"] = "1" }, + captureErrors: true, out _); + await process!.WaitForExitAsync(); + + Assert.Equal("1", Assert.Single(process.ErrorTail).Trim()); + } } diff --git a/StarterNG.Tests/StartSimulationTests.cs b/StarterNG.Tests/StartSimulationTests.cs index 2940fa6..81d3ece 100644 --- a/StarterNG.Tests/StartSimulationTests.cs +++ b/StarterNG.Tests/StartSimulationTests.cs @@ -1,5 +1,7 @@ using StarterNG.Application; using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Infrastructure; using StarterNG.Infrastructure.Sceneries; using StarterNG.Tests.Fakes; @@ -23,6 +25,58 @@ public class StartSimulationTests Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory); } + [Fact] + public void With_the_physics_log_on_the_launch_is_noted_for_the_tapes() + { + var (start, launcher, installation) = Rig(files => files.WithExecutable(Build), sceneryClock: "07:52"); + installation.SettingsStore.Settings.PhysicsLog = true; + + start.Execute(freeFly: false, saveSettings: false); + + // Under the name the simulator is given, which is the name it logs under. + string driven = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1]; + string noted = installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")); + Assert.Contains("clock\t07:52", noted); + Assert.Contains($"vehicle\t{driven}\tPKP/EP07\tep07.mmd", noted); + } + + [Fact] + public void Without_the_physics_log_nothing_is_noted() + { + var (start, _, installation) = Rig(files => files.WithExecutable(Build)); + + start.Execute(freeFly: false, saveSettings: false); + + Assert.False(installation.Files.FileExists(TestInstallation.At("physicslog", "starter-launch.txt"))); + } + + [Theory] + [InlineData("v2")] + [InlineData("V2")] + [InlineData("gone")] + [InlineData(null)] + [InlineData("")] + public void A_crew_is_there_to_start_with_when_the_pick_has_one_or_names_nothing(string? preferred) => + Assert.True(StartSimulation.HasCrewToStart(WagonAndLocomotive, preferred)); + + [Fact] + public void A_pick_with_nobody_in_it_says_no_crew() => + Assert.False(StartSimulation.HasCrewToStart(WagonAndLocomotive, "v1")); + + [Fact] + public void A_consist_nobody_sits_in_has_no_crew_to_start() + { + var empty = new Trainset("trainset a tor 0 0\nnode -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\nendtrainset"); + + Assert.False(StartSimulation.HasCrewToStart(empty, null)); + } + + private static Trainset WagonAndLocomotive => new( + "trainset a tor 0 0\n" + + "node -1 0 v1 dynamic d s m 0 nobody 3 0 enddynamic\n" + + "node -1 0 v2 dynamic d s m 0 headdriver 3 0 enddynamic\n" + + "endtrainset"); + [Fact] public void A_build_for_the_other_system_is_still_launched() { @@ -54,6 +108,49 @@ public class StartSimulationTests Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory); } + [Fact] + public void Wine_is_pointed_at_the_settings_the_starter_saves() + { + string wine = Path.Combine("/usr/bin", "wine"); + string exe = Path.Combine(Path.GetTempPath(), "maszyna", "eu07.exe"); + const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming"; + var (start, _, installation) = Rig(files => files + .WithExecutable(wine) + .WithFile(exe, "MZ\u0090\u0000") + .WithDirectory(roaming)); + installation.Environment.With("PATH", "/usr/bin").With("HOME", "/home/kolejarz").With("USER", "kolejarz"); + installation.SettingsStore.Settings.ExecutablePath = exe; + + start.Execute(freeFly: true, saveSettings: false); + + Assert.Equal(Path.GetFullPath(Path.Combine("/home/kolejarz", ".config", "MaSzyna")), + installation.Files.LinkTarget(Path.Combine(roaming, "MaSzyna"))); + } + + [Fact] + public void A_native_build_leaves_the_wine_prefix_alone() + { + const string roaming = "/home/kolejarz/.wine/drive_c/users/kolejarz/AppData/Roaming"; + var (start, _, installation) = Rig(files => files.WithExecutable(Build).WithDirectory(roaming)); + installation.Environment.With("HOME", "/home/kolejarz").With("USER", "kolejarz"); + + start.Execute(freeFly: true, saveSettings: false); + + Assert.Null(installation.Files.LinkTarget(roaming + "/MaSzyna")); + } + + [Theory] + [InlineData(0)] + [InlineData(3)] + public void Any_renderer_is_left_for_the_simulator_to_take(int renderer) + { + var (start, launcher, installation) = Rig(files => files.WithExecutable(Build, "\u007fELF binary")); + installation.SettingsStore.Settings.RenderEngine = renderer; + + Assert.Equal(SimulationStartOutcome.Started, start.Execute(freeFly: true, saveSettings: false).Outcome); + Assert.NotNull(launcher.Executable); + } + [Fact] public void The_error_stream_is_kept_only_while_the_starter_stays_to_read_it() { @@ -80,6 +177,28 @@ public class StartSimulationTests Assert.False(installation.Files.FileExists(TestInstallation.At("scenery", "$td.scn"))); } + [Fact] + public void A_new_scenery_opens_at_the_wizard_even_with_a_scenery_selected() + { + var (start, launcher, _) = Rig(files => files.WithExecutable(Build)); + + var result = start.ExecuteNewSceneryEditor(); + + Assert.Equal(SimulationStartOutcome.Started, result.Outcome); + Assert.Equal(new[] { "-edit" }, launcher.Arguments); + } + + [Fact] + public void With_no_scenery_selected_the_editor_opens_at_its_wizard() + { + var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentScenery = null); + + var result = start.ExecuteEditor(); + + Assert.Equal(SimulationStartOutcome.Started, result.Outcome); + Assert.Equal(new[] { "-edit" }, launcher.Arguments); + } + [Fact] public void A_file_that_cannot_be_run_is_not() { @@ -92,8 +211,134 @@ public class StartSimulationTests Assert.Null(launcher.Executable); } + [Fact] + public void A_scenery_with_no_consist_picked_starts_with_the_free_camera() + { + var (start, launcher, _) = Rig(files => files.WithExecutable(Build), state => state.CurrentTrainset = null); + + var result = start.Execute(freeFly: false, saveSettings: false); + + Assert.Equal(SimulationStartOutcome.Started, result.Outcome); + Assert.Equal(new[] { "-s", "$td.scn" }, launcher.Arguments); + } + + [Fact] + public void The_copy_for_a_start_is_named_in_lower_case_as_the_simulator_looks_for_it() + { + var (start, launcher, _) = Rig(files => files.WithExecutable(Build), sceneryFile: "Glinojeck.scn"); + + start.Execute(freeFly: true, saveSettings: false); + + Assert.Equal(new[] { "-s", "$glinojeck.scn" }, launcher.Arguments); + } + + [Fact] + public void A_free_camera_start_without_a_consist_notes_no_driven_vehicle_for_the_tapes() + { + var (start, launcher, installation) = Rig(files => files.WithExecutable(Build), + state => state.CurrentTrainset = null); + installation.SettingsStore.Settings.PhysicsLog = true; + + start.Execute(freeFly: true, saveSettings: false); + + Assert.NotNull(launcher.Executable); + var note = StarterNG.Domain.Tapes.TapeLaunch.Parse( + installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!; + Assert.Null(note.Driven); + Assert.Empty(note.Consist!); + } + + [Fact] + public void A_start_in_a_vehicle_notes_it_and_its_consist_for_the_tapes() + { + var (start, launcher, installation) = Rig(files => files.WithExecutable(Build)); + installation.SettingsStore.Settings.PhysicsLog = true; + + start.Execute(freeFly: false, saveSettings: false); + + // Under the name the simulator is given, which is the name it logs under. + string given = launcher.Arguments![launcher.Arguments.ToList().IndexOf("-v") + 1]; + var note = StarterNG.Domain.Tapes.TapeLaunch.Parse( + installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!; + Assert.Equal(given, note.Driven); + Assert.Equal(new[] { given }, note.Consist); + } + + [Fact] + public void A_start_notes_the_scenario_and_the_train_of_the_timetable_for_the_tapes() + { + var (start, _, installation) = Rig(files => + { + files.WithExecutable(Build); + files.WithFile(TestInstallation.At("scenery", "pociag.txt"), + "[ Rodzaj i numer pociagu | MPJ15120 ]\r\n"); + }); + installation.SettingsStore.Settings.PhysicsLog = true; + + start.Execute(freeFly: false, saveSettings: false); + + var note = StarterNG.Domain.Tapes.TapeLaunch.Parse( + installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!; + Assert.Equal("Test", note.SceneryName); + Assert.Equal("MPJ15120", note.Train); + } + + [Fact] + public void A_free_camera_start_notes_no_train_for_the_tapes() + { + var (start, _, installation) = Rig(files => files.WithExecutable(Build)); + installation.SettingsStore.Settings.PhysicsLog = true; + + start.Execute(freeFly: true, saveSettings: false); + + var note = StarterNG.Domain.Tapes.TapeLaunch.Parse( + installation.Files.ReadAllText(TestInstallation.At("physicslog", "starter-launch.txt")))!; + Assert.Null(note.Train); + } + + private static void HybridLaptop(InMemoryFileSystem files) + { + files.WithFile("/sys/class/drm/card0/device/uevent", + "DRIVER=nvidia\nPCI_ID=10DE:2D19\nPCI_SLOT_NAME=0000:01:00.0\n") + .WithFile("/sys/class/drm/card0/device/boot_vga", "0\n") + .WithFile("/sys/class/drm/card1/device/uevent", + "DRIVER=amdgpu\nPCI_ID=1002:1900\nPCI_SLOT_NAME=0000:65:00.0\n") + .WithFile("/sys/class/drm/card1/device/boot_vga", "1\n") + .WithFile("/sys/class/drm/card1-eDP-1/status", "connected\n"); + } + + [Fact] + public void On_a_laptop_with_two_cards_the_simulator_goes_to_the_discrete_one() + { + var (start, launcher, _) = Rig(files => + { + files.WithExecutable(Build); + HybridLaptop(files); + }); + + start.Execute(freeFly: true, saveSettings: false); + + Assert.Equal("1", launcher.Environment!["__NV_PRIME_RENDER_OFFLOAD"]); + } + + [Fact] + public void Left_to_the_system_the_simulator_gets_no_card_of_the_starters_choosing() + { + var (start, launcher, installation) = Rig(files => + { + files.WithExecutable(Build); + HybridLaptop(files); + }); + installation.SettingsStore.Settings.Gpu = StarterNG.Domain.Settings.GpuPreference.System; + + start.Execute(freeFly: true, saveSettings: false); + + Assert.Empty(launcher.Environment!); + } + private static (StartSimulation Start, RecordingLauncher Launcher, TestInstallation Installation) Rig( - Action addSimulator) + Action addSimulator, Action? arrange = null, string sceneryFile = "td.scn", + string sceneryClock = "10:30") { var installation = new TestInstallation(); addSimulator(installation.Files); @@ -103,7 +348,7 @@ public class StartSimulationTests settings.ExecutablePath = Build; var scenery = new SceneryParser(installation.Clock, installation.Random).Parse( - TestInstallation.At("scenery", "td.scn"), + TestInstallation.At("scenery", sceneryFile), """ //$n Test trainset pociag tor_1 10 0 @@ -111,12 +356,20 @@ public class StartSimulationTests endtrainset FirstInit """); + scenery.Weather.ScenarioTimeOverride = sceneryClock; var state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] }; + arrange?.Invoke(state); var launcher = new RecordingLauncher(); var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles, - installation.Executables, installation.Files, installation.Paths, launcher, - installation.Random, installation.Log); + installation.Executables, + installation.Files, installation.Paths, launcher, + installation.Random, installation.Log, + new LinuxGpus(installation.Files, installation.Environment), + installation.Environment, + new WineConfigLink(installation.Files, installation.Environment, + installation.SettingsPaths, installation.Log), + installation.Tapes); return (start, launcher, installation); } @@ -130,9 +383,13 @@ public class StartSimulationTests public bool? CapturedErrors { get; private set; } + public IReadOnlyDictionary? Environment { get; private set; } + public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, - bool captureErrors, out string? error) + IReadOnlyDictionary environment, bool captureErrors, + out string? error) { + Environment = environment; Executable = executablePath; CapturedErrors = captureErrors; Arguments = arguments; @@ -144,6 +401,8 @@ public class StartSimulationTests public bool OpenInShell(string pathOrUrl) => false; public IProcessHandle? FindRunning(string processName) => null; + + public bool IsRunning(string processName) => false; } private sealed class RunningProcess : IProcessHandle diff --git a/StarterNG/Application/Abstractions/IProcessLauncher.cs b/StarterNG/Application/Abstractions/IProcessLauncher.cs index a62eacf..f93d56b 100644 --- a/StarterNG/Application/Abstractions/IProcessLauncher.cs +++ b/StarterNG/Application/Abstractions/IProcessLauncher.cs @@ -20,14 +20,17 @@ public interface IProcessHandle public interface IProcessLauncher { /// - /// Starts a process. With captureErrors its error stream is kept for the - /// handle - only for a process the starter outlives: a write to a pipe nobody - /// reads any more can kill the writer. + /// Starts a process, with added to the starter's + /// own. With captureErrors its error stream is kept for the handle - only for a + /// process the starter outlives: a write to a pipe nobody reads any more can + /// kill the writer. /// IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, - bool captureErrors, out string? error); + IReadOnlyDictionary environment, bool captureErrors, out string? error); bool OpenInShell(string pathOrUrl); IProcessHandle? FindRunning(string processName); + + bool IsRunning(string processName); } diff --git a/StarterNG/Application/SimulatorAftermath.cs b/StarterNG/Application/SimulatorAftermath.cs index 90f63c7..ad39a80 100644 --- a/StarterNG/Application/SimulatorAftermath.cs +++ b/StarterNG/Application/SimulatorAftermath.cs @@ -10,6 +10,9 @@ namespace StarterNG.Application; /// What a simulator that went down left behind: its log and its crash dumps. public sealed class SimulatorAftermath { + // A little slack: the start is taken just after the launch, not before it. + private static readonly TimeSpan StartSlack = TimeSpan.FromSeconds(5); + private readonly IFileSystem _files; private readonly IGamePaths _paths; private readonly IDiagnosticsLog _log; @@ -42,8 +45,7 @@ public sealed class SimulatorAftermath if (_files.DirectoryExists(crashpad)) dumps.AddRange(_files.GetFilesRecursive(crashpad, "*.dmp")); - // A little slack: the start is taken just after the launch, not before it. - var since = startedUtc - TimeSpan.FromSeconds(5); + var since = startedUtc - StartSlack; return dumps.Distinct(StringComparer.OrdinalIgnoreCase) .Where(dump => _files.GetLastWriteTimeUtc(dump) >= since) .OrderByDescending(_files.GetLastWriteTimeUtc) @@ -56,13 +58,17 @@ public sealed class SimulatorAftermath } } - /// The end of the simulator's own log, for when it died without a word on its error stream. - public IReadOnlyList SimulatorLogTail(int count) + /// + /// The end of the simulator's own log, for when it died without a word on its + /// error stream. A log not written since the start is the session before's, the + /// simulator having died before opening its own: it says nothing about this one. + /// + public IReadOnlyList SimulatorLogTail(int count, DateTime startedUtc) { string path = _paths.FromRoot("log.txt"); try { - if (!_files.FileExists(path)) + if (!_files.FileExists(path) || _files.GetLastWriteTimeUtc(path) < startedUtc - StartSlack) return Array.Empty(); var lines = LegacyText.Decode(_files.ReadAllBytes(path)) diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs index d8da998..071e5e4 100644 --- a/StarterNG/Application/StartSimulation.cs +++ b/StarterNG/Application/StartSimulation.cs @@ -2,12 +2,12 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using StarterNG.Application.Abstractions; using StarterNG.Classes; using StarterNG.Domain; using StarterNG.Domain.Settings; using StarterNG.Domain.Vehicles; +using StarterNG.Infrastructure; using StarterNG.Infrastructure.Adapters; using StarterNG.Infrastructure.Settings; @@ -47,11 +47,21 @@ public sealed class StartSimulation private readonly IProcessLauncher _processes; private readonly IRandomSource _random; private readonly IDiagnosticsLog _log; + private readonly LinuxGpus _gpus; + private readonly IEnvironment _environment; + private readonly WineConfigLink _wineConfig; + private readonly HaslerTapes _tapes; public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles, ExecutableLocator executables, IFileSystem files, IGamePaths paths, - IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log) + IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log, + LinuxGpus gpus, IEnvironment environment, WineConfigLink wineConfig, + HaslerTapes tapes) { + _gpus = gpus; + _wineConfig = wineConfig; + _environment = environment; + _tapes = tapes; _state = state; _settings = settings; _vehicles = vehicles; @@ -63,11 +73,12 @@ public sealed class StartSimulation _log = log; } - public static string? StartableVehicle(Trainset? trainset, string? preferred) + /// + /// What the start button says of the consist. The vehicle picked answers for it + /// even with nobody in it, where the launch itself goes on to the first crew. + /// + public static bool HasCrewToStart(Trainset trainset, string? preferred) { - if (trainset is null || trainset.Vehicles.Count == 0) - return null; - static bool CanStart(Dynamic vehicle) => vehicle.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; @@ -75,39 +86,49 @@ public sealed class StartSimulation { var picked = trainset.Vehicles.FirstOrDefault(vehicle => string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase)); - if (picked is not null && CanStart(picked)) - return picked.Name; + if (picked is not null) + return CanStart(picked); } - return trainset.Vehicles.FirstOrDefault(CanStart)?.Name; + return trainset.Vehicles.Any(CanStart); } public SimulationStartResult Execute(bool freeFly, bool saveSettings) { var scenery = _state.CurrentScenery; var trainset = _state.CurrentTrainset; - if (scenery is null || trainset is null) + if (scenery is null) return new SimulationStartResult(SimulationStartOutcome.NothingSelected); - // The depot checkbox is the consist's own state; this setting is a blanket - // override for people who always want the same thing, and it wins. - switch (_settings.Settings.ReadyOverride) + // A scenery with no consist picked starts with the free camera. + string? vehicle = null; + if (trainset is null) { - case ConsistReadyOverride.AlwaysCold: - trainset.ReadyToGo = false; - break; - case ConsistReadyOverride.AlwaysReady: - trainset.ReadyToGo = true; - break; + freeFly = true; } + else + { + // The depot checkbox is the consist's own state; this setting is a blanket + // override for people who always want the same thing, and it wins. + switch (_settings.Settings.ReadyOverride) + { + case ConsistReadyOverride.AlwaysCold: + trainset.ReadyToGo = false; + break; + case ConsistReadyOverride.AlwaysReady: + trainset.ReadyToGo = true; + break; + } - string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles) - ?? StartableVehicle(trainset, _state.StartingVehicleName); - _state.StartingVehicleName = vehicle; + vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles); + _state.StartingVehicleName = vehicle; + } scenery.Weather.Dirty = true; - string exportName = ExportPrefix + Path.GetFileName(scenery.Path); + // In lower case: that is the name the simulator looks for, and on Linux + // "$Glinojeck.scn" is not "$glinojeck.scn". + string exportName = ExportPrefix + SimulatorNames.Lower(Path.GetFileName(scenery.Path)); string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName); try { @@ -125,6 +146,9 @@ public sealed class StartSimulation if (saveSettings) _settings.CaptureAndSave(); + if (_settings.Settings.PhysicsLog) + _tapes.RecordLaunch(scenery, trainset, freeFly ? null : vehicle); + string[] arguments = freeFly || string.IsNullOrEmpty(vehicle) ? new[] { "-s", exportName } : new[] { "-s", exportName, "-v", vehicle }; @@ -134,15 +158,16 @@ public sealed class StartSimulation /// /// Opens the selected scenery in the simulator's editor. The scenery file itself, - /// not an export: what the editor saves has to land in the scenery. + /// not an export: what the editor saves has to land in the scenery. With none + /// selected the simulator opens its wizard of a new scenery. /// - public SimulationStartResult ExecuteEditor() - { - if (_state.CurrentScenery is not { } scenery) - return new SimulationStartResult(SimulationStartOutcome.NothingSelected); + public SimulationStartResult ExecuteEditor() => + Launch(_state.CurrentScenery is { } scenery + ? new[] { "-edit", Path.GetFileName(scenery.Path) } + : new[] { "-edit" }); - return Launch(new[] { "-edit", Path.GetFileName(scenery.Path) }); - } + /// The simulator's wizard of a new scenery, whichever one is selected. + public SimulationStartResult ExecuteNewSceneryEditor() => Launch(new[] { "-edit" }); private SimulationStartResult Launch(IReadOnlyList arguments) { @@ -157,10 +182,14 @@ public sealed class StartSimulation // The installation, not the folder of the binary: a simulator picked from a // build tree lives beside the data, and the data is what it opens. var (program, programArguments) = _executables.LaunchCommand(executable, arguments); + // Under Wine eu07.exe would read the prefix's own eu07.ini, not the one just saved. + if (_executables.RunsThroughWine(executable)) + _wineConfig.Ensure(); // Its error stream is kept for a report on a crash - unless the starter closes // now, leaving nobody to read it or to report. bool watched = !_settings.Settings.AutoCloseStarter; - var process = _processes.Start(program, programArguments, _paths.Root, watched, out string? error); + var process = _processes.Start(program, programArguments, _paths.Root, GraphicsCard(), watched, + out string? error); if (process is null) return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable, Detail: error); @@ -168,4 +197,30 @@ public sealed class StartSimulation return new SimulationStartResult(SimulationStartOutcome.Started, process, executable); } + /// + /// What puts the simulator on the card the settings ask for, said in the log + /// either way: a slow simulator on a laptop is the first thing to ask about. + /// + private IReadOnlyDictionary GraphicsCard() + { + var gpus = _gpus.List(); + if (gpus.Count < 2) + return new Dictionary(); + + var preference = _settings.Settings.Gpu; + string cards = string.Join(", ", gpus); + if (GpuOffload.Target(gpus, preference, _environment.GetVariable) is not { } target) + { + _log.Log($"GPU: {cards}; left to the system ({(preference == GpuPreference.System + ? "chosen in the settings" + : "set by the player, or no card to move to")})"); + return new Dictionary(); + } + + var environment = GpuOffload.EnvironmentFor(target); + _log.Log($"GPU: {cards}; simulator on {target} with " + + string.Join(' ', environment.Select(pair => $"{pair.Key}={pair.Value}"))); + return environment; + } + } diff --git a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs index 59ceacd..45cf7b3 100644 --- a/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs +++ b/StarterNG/Infrastructure/Adapters/SystemProcessLauncher.cs @@ -18,7 +18,8 @@ public sealed class SystemProcessLauncher : IProcessLauncher } public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, - bool captureErrors, out string? error) + IReadOnlyDictionary environment, bool captureErrors, + out string? error) { var info = new ProcessStartInfo { @@ -29,6 +30,8 @@ public sealed class SystemProcessLauncher : IProcessLauncher }; foreach (string argument in arguments) info.ArgumentList.Add(argument); + foreach (var (name, value) in environment) + info.Environment[name] = value; try { @@ -87,6 +90,22 @@ public sealed class SystemProcessLauncher : IProcessLauncher } } + public bool IsRunning(string processName) + { + try + { + var found = Process.GetProcessesByName(processName); + foreach (var process in found) + process.Dispose(); + return found.Length > 0; + } + catch (Exception ex) + { + _log.Log($"lookup {processName}", ex); + return false; + } + } + private sealed class ProcessHandle : IProcessHandle { private const int KeptErrorLines = 20; diff --git a/StarterNG/Views/Scenarios.axaml b/StarterNG/Views/Scenarios.axaml index c8580a4..99a4a19 100644 --- a/StarterNG/Views/Scenarios.axaml +++ b/StarterNG/Views/Scenarios.axaml @@ -4,9 +4,13 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:starterNg="clr-namespace:StarterNG" xmlns:infra="clr-namespace:StarterNG.Infrastructure" + xmlns:materialIcons="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="StarterNG.Views.Scenarios" x:CompileBindings="True"> + + + @@ -72,7 +76,7 @@ - + @@ -98,7 +102,7 @@ + RowDefinitions="Auto,6,Auto,14,Auto,Auto,8,Auto,Auto,10,Auto,10,Auto"> - @@ -176,7 +180,7 @@ - @@ -217,6 +221,26 @@ + + + + + + + + + + + + + + + diff --git a/StarterNG/Views/Scenarios.axaml.cs b/StarterNG/Views/Scenarios.axaml.cs index 18b547e..08e942c 100644 --- a/StarterNG/Views/Scenarios.axaml.cs +++ b/StarterNG/Views/Scenarios.axaml.cs @@ -5,7 +5,6 @@ using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; -using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using Avalonia; @@ -14,7 +13,6 @@ using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; using Avalonia.Layout; -using Avalonia.Markup.Xaml; using Avalonia.Media.Imaging; using Avalonia.VisualTree; using StarterNG.Classes; @@ -30,6 +28,8 @@ public partial class Scenarios : UserControl { public List Sceneries; + private readonly VehicleInfo _info = new(AppServices.Current.Library.Vehicles, AppServices.Current.Physics); + private Scenery? _currentScenery; private bool _loadingWeather; @@ -62,10 +62,27 @@ public partial class Scenarios : UserControl private void OnReentered() { - RefreshVehicleLabels(); + RefreshVehicleList(); RefreshSelectedConsist(); } + /// + /// The list again, as the depot page may have picked another consist or filled + /// one. Selecting its row counts as no pick here: the vehicle to start in stays. + /// + private void RefreshVehicleList() + { + if (SelectedScenery is not { } scenery) + return; + + var state = AppServices.Current.State; + var trainset = state.CurrentTrainset; + string? vehicle = state.StartingVehicleName; + PopulateVehicleList(scenery); + if (ReferenceEquals(state.CurrentTrainset, trainset)) + state.StartingVehicleName = vehicle; + } + private void BuildSceneryTree() { sceneryList.Items.Clear(); @@ -81,11 +98,11 @@ public partial class Scenarios : UserControl sceneryList.Items.Add(ToTreeItem(node, expandGroups)); } - private static TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups) + private TreeViewItem ToTreeItem(SceneryTreeNode node, bool expandGroups) { var item = new TreeViewItem { - Header = node.Label, + Header = node.IsGroup ? node.Label : SceneryHeader(node.Label, Sceneries[node.SceneryIndex]), Tag = node.IsGroup ? null : node.SceneryIndex, IsExpanded = node.IsGroup && expandGroups }; @@ -96,6 +113,27 @@ public partial class Scenarios : UserControl return item; } + /// The scenery's name, marked when its files hold mistakes. + private object SceneryHeader(string label, Scenery scenery) + { + if (scenery.Faults.Count == 0) + return label; + + var mark = new Material.Icons.Avalonia.MaterialIcon + { + Kind = Material.Icons.MaterialIconKind.AlertOutline, + Width = 13, + Height = 13, + Margin = new Thickness(5, 0, 0, 0), + Foreground = (Avalonia.Media.IBrush)Resources["SyntaxFaultBrush"]! + }; + var header = new StackPanel { Orientation = Orientation.Horizontal }; + header.Children.Add(new TextBlock { Text = label, VerticalAlignment = VerticalAlignment.Center }); + header.Children.Add(mark); + ToolTip.SetTip(mark, string.Format(App.Loc["SyntaxTreeTip"], scenery.Faults.Count)); + return header; + } + private Scenery? SelectedScenery => sceneryList.SelectedItem is TreeViewItem { Tag: int index } && index < Sceneries.Count ? Sceneries[index] @@ -215,14 +253,11 @@ public partial class Scenarios : UserControl AppServices.Current.SettingsStore.Save(); } - private static string TrainsetListLabel(Trainset trainset) => - TrainsetDisplay.CompositionText(trainset) - ?? string.Format(App.Loc["EmptyTrainset"], trainset.Track ?? ""); - - private static Control TrainsetListContent(Trainset trainset) => + /// A consist's label in a list of them, here and on the depot page. + internal static TextBlock TrainsetListContent(Trainset trainset) => new Avalonia.Controls.TextBlock { - Text = TrainsetListLabel(trainset), + Text = TrainsetDisplay.ListLabel(trainset), TextTrimming = Avalonia.Media.TextTrimming.CharacterEllipsis, TextWrapping = Avalonia.Media.TextWrapping.NoWrap }; @@ -233,13 +268,14 @@ public partial class Scenarios : UserControl bool showAi = AppServices.Current.Settings.ShowAiVehicles; bool drivableOnly = AppServices.Current.Settings.DrivableOnly; + var current = AppServices.Current.State.CurrentTrainset; + ListBoxItem? currentRow = null; for (int i = 0; i < scn.Trainsets.Count; i++) { var trainset = scn.Trainsets[i]; - if (IsAiTrainset(trainset) && !showAi) continue; - if (drivableOnly && !UData.StaffedTrain(trainset)) continue; + if (!TrainsetDisplay.ListedIn(trainset, showAi, drivableOnly, current)) continue; var listItem = new ListBoxItem { @@ -250,17 +286,27 @@ public partial class Scenarios : UserControl listItem.ContextMenu = ConsistContextMenu.Build( listItem, () => trainset, - vehicles => ApplyConsist(listItem, trainset, vehicles)); + vehicles => ApplyConsist(trainset, vehicles, listItem), + removeAll: () => ApplyConsist(trainset, new List(), listItem)); vehicleList.Items.Add(listItem); + + if (ReferenceEquals(trainset, current)) + currentRow = listItem; } if (vehicleList.Items.Count > 0) - vehicleList.SelectedIndex = 0; + vehicleList.SelectedItem = currentRow ?? vehicleList.Items[0]; + else + ClearConsistStrip(); } - private static bool IsAiTrainset(Trainset trainset) => - !string.IsNullOrEmpty(trainset.Description) && - trainset.Description.TrimStart().StartsWith("-", StringComparison.Ordinal); + /// No consist to show: what the scenery before had listed is not left standing. + private void ClearConsistStrip() + { + consistStack.Children.Clear(); + _stripTrainset = null; + trainStats.Children.Clear(); + } private void ArchivalSwitch_OnChanged(object? sender, RoutedEventArgs e) { @@ -273,20 +319,39 @@ public partial class Scenarios : UserControl RestoreLastScenery(); } - private void ApplyConsist(ListBoxItem item, Trainset trainset, List vehicles) + /// + /// The list row it was chosen on, which may be of a consist not the picked one. + /// Without one it comes from the strip, which redraws whichever consist it shows. + /// + private void ApplyConsist(Trainset trainset, List vehicles, ListBoxItem? row = null) { trainset.Vehicles = vehicles; - item.Content = TrainsetListContent(trainset); - if (ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset)) + if (row is null) + { + RedrawConsist(trainset); + } + else + { + row.Content = TrainsetListContent(trainset); + if (!ReferenceEquals(AppServices.Current.State.CurrentTrainset, trainset)) + return; + ShowConsist(trainset); + } + AppServices.Current.State.NotifyChanged(); + } + + private void RedrawConsist(Trainset trainset) + { + RefreshVehicleLabels(); + ShowConsist(trainset); } private void RefreshVehicleLabels() { - if (sceneryList.SelectedItem is not TreeViewItem { Tag: int tag } || tag >= Sceneries.Count) + if (SelectedScenery is not { } scn) return; - var scn = Sceneries[tag]; foreach (var obj in vehicleList.Items) { if (obj is ListBoxItem { Tag: int vi } lbi && vi < scn.Trainsets.Count) @@ -313,6 +378,17 @@ public partial class Scenarios : UserControl } Scenery selectedScn = Sceneries[tag]; + // The scenery is the one started from now on, whether or not it lists a + // consist: left as it was, a scenery with none - or none the filters show - + // started the one picked before. + var state = AppServices.Current.State; + if (!ReferenceEquals(state.CurrentScenery, selectedScn)) + { + state.CurrentScenery = selectedScn; + state.CurrentTrainset = null; + state.StartingVehicleName = null; + } + ShowSceneryInfo(selectedScn); ShowWeather(selectedScn); PersistLastScenery(selectedScn); @@ -320,7 +396,7 @@ public partial class Scenarios : UserControl missionDescription.Text = ""; timetableContent.Text = App.Loc["NoTimetable"]; - PopulateVehicleList(selectedScn); + RefreshVehicleList(); } private void ShowSceneryInfo(Scenery scenery) @@ -348,18 +424,36 @@ public partial class Scenarios : UserControl PopulateAttachments(scenery); PopulateFaults(scenery); + LogUnknownTextures(scenery); } + /// Most a scenery's faults the tab lists; the rest are only counted. + private const int FaultsShown = 500; + private void PopulateFaults(Scenery scenery) { - var lines = new List(); - lines.Add(StarterNG.Domain.Sceneries.SceneryFaultText.Format(scenery.Faults, 500, key => App.Loc[key])); + var faults = scenery.Faults; + faultsTab.IsVisible = faults.Count > 0; + if (faults.Count == 0) + { + if (infoTabs.SelectedItem == faultsTab) + infoTabs.SelectedIndex = 0; + faultsText.Text = ""; + } + else + { + faultsTabHeader.Text = string.Format(App.Loc["SyntaxTab"], faults.Count); + faultsText.Text = SceneryFaultText.Format(faults, FaultsShown, key => App.Loc[key]); + } + } + // Textures the vehicle library does not know are no fault of the text, only noted. + private static void LogUnknownTextures(Scenery scenery) + { + var lines = new List(); var db = AppServices.Current.Library.Vehicles; foreach (var ts in scenery.Trainsets) { - if (!ts.Parsed) - lines.Add($"# unparsed trainset: {ts.Name}"); foreach (var v in ts.Vehicles) { if (string.IsNullOrEmpty(v.SkinFile)) continue; @@ -393,6 +487,9 @@ public partial class Scenarios : UserControl }; btn.Classes.Add("Flat"); ToolTip.SetTip(btn, att.FilePath); + // A button that does nothing is no way to say the file is not there. + btn.IsEnabled = File.Exists(Path.GetFullPath(att.FilePath)); + ToolTip.SetShowOnDisabled(btn, true); btn.Click += (_, _) => OpenAttachment(att.FilePath); attachmentsPanel.Children.Add(btn); } @@ -421,8 +518,7 @@ public partial class Scenarios : UserControl BuildSceneryTree(); if (!string.IsNullOrEmpty(selected)) { - AppServices.Current.Settings.LastScenery = - Path.GetFileNameWithoutExtension(selected); + RememberScenery(selected); RestoreLastScenery(); } if (AppServices.Current.State.CurrentScenery != null) @@ -430,6 +526,13 @@ public partial class Scenarios : UserControl RefreshSelectedConsist(); } + /// The one the tree opens on next, if there was one. + private static void RememberScenery(string? path) + { + if (!string.IsNullOrEmpty(path)) + AppServices.Current.Settings.LastScenery = Path.GetFileNameWithoutExtension(path); + } + private bool _reloading; private async void ReloadSceneries_OnClick(object? sender, RoutedEventArgs e) @@ -438,15 +541,18 @@ public partial class Scenarios : UserControl _reloading = true; try { + // Reading the files again drops every change not saved to them. + if (Sceneries.Any(s => s.Trainsets.Any(SceneryTrainsetText.IsModified)) && + TopLevel.GetTopLevel(this) is Window owner && + !await MessageBox.Show(owner, App.Loc["ReloadSceneriesConfirm"], + App.Loc["ReloadSceneries"], MessageBoxButtons.YesNo)) + return; + string? selected = AppServices.Current.State.CurrentScenery?.Path; await Task.Run(() => AppServices.Current.Library.ReloadSceneries()); Sceneries = AppServices.Current.Library.Sceneries; BuildSceneryTree(); - if (!string.IsNullOrEmpty(selected)) - { - AppServices.Current.Settings.LastScenery = - Path.GetFileNameWithoutExtension(selected); - } + RememberScenery(selected); RestoreLastScenery(); } finally @@ -455,6 +561,62 @@ public partial class Scenarios : UserControl } } + /// + /// The scenery files changed outside the starter: the list follows, and only a + /// consist edited here whose text changed in the file too has to ask. + /// + /// False while the window is in the background: asked once it is back. + public async Task FollowSceneryFilesAsync(IReadOnlyList changes, bool mayAsk) + { + if (_reloading) return; + + var watch = AppServices.Current.SceneryFiles; + bool followed = false; + foreach (var change in changes) + { + var judgement = watch.Judge(change); + if (judgement.Verdict == SceneryVerdict.Leave) + continue; + + if (judgement.Verdict == SceneryVerdict.AskFirst) + { + if (!mayAsk || TopLevel.GetTopLevel(this) is not Window owner) + continue; + + string names = string.Join(", ", judgement.Lost.Select(t => t.Name)); + if (!await MessageBox.Show(owner, + string.Format(App.Loc["SceneryFileChangedConfirm"], Path.GetFileName(change.Current!.Path), names), + App.Loc["SceneryFileChangedTitle"], MessageBoxButtons.YesNo)) + { + change.Current!.Sources = change.Fresh!.Sources; + continue; + } + } + + watch.Apply(change); + followed = true; + } + + if (followed) + RebuildKeepingGroupsOpen(); + } + + private void RebuildKeepingGroupsOpen() + { + var open = TreeGroups(sceneryList.Items).Where(g => g.IsExpanded).Select(g => g.Header).ToHashSet(); + string? selected = AppServices.Current.State.CurrentScenery?.Path; + + BuildSceneryTree(); + foreach (var group in TreeGroups(sceneryList.Items)) + group.IsExpanded = open.Contains(group.Header); + + RememberScenery(selected); + RestoreLastScenery(); + } + + private static IEnumerable TreeGroups(ItemCollection items) => + items.OfType().Where(i => i.Tag is null).SelectMany(i => TreeGroups(i.Items).Prepend(i)); + public event Action? RandomizeTexturesRequested; private void RandomTexturesButton_OnClick(object? sender, RoutedEventArgs e) @@ -519,29 +681,14 @@ public partial class Scenarios : UserControl string dir = scn != null ? (Path.GetDirectoryName(Path.GetFullPath(scn.Path)) ?? "scenery") : Path.GetFullPath("scenery"); - if (!Directory.Exists(dir)) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread(string.Format(App.Loc["FaultFileNotFound"], dir)); - return; - } - try - { - Process.Start(new ProcessStartInfo(dir) { UseShellExecute = true }); - } - catch (Exception ex) - { - StarterNG.Infrastructure.Diagnostics.ReportOnUiThread( - $"{dir}{Environment.NewLine}{Environment.NewLine}{App.Loc["FaultDetail"]} {ex.Message}"); - } + StarterNG.Infrastructure.Diagnostics.OpenOrReport(dir); } private void VehicleList_OnSelectionChanged(object? sender, SelectionChangedEventArgs e) { - var tItem = sceneryList.SelectedItem as TreeViewItem; - if (tItem?.Tag is not int tTag) + if (SelectedScenery is not { } selectedScn) return; - Scenery selectedScn = Sceneries[tTag]; ListBoxItem? vItem = vehicleList.SelectedItem as ListBoxItem; if (vItem?.Tag is not int vTag) @@ -827,14 +974,8 @@ public partial class Scenarios : UserControl ContextMenu VehicleMenu(Dynamic vehicle) => ConsistContextMenu.Build( consistStack, () => trainset, - vehicles => - { - trainset.Vehicles = vehicles; - RefreshVehicleLabels(); - ShowConsist(trainset); - UpdateTrainStats(trainset); - }, - removeAll: null, + vehicles => ApplyConsist(trainset, vehicles), + removeAll: () => ApplyConsist(trainset, new List()), getVehicle: () => vehicle, removeVehicle: v => { @@ -851,9 +992,7 @@ public partial class Scenarios : UserControl AppServices.Current.State.NotifyChanged(); } - RefreshVehicleLabels(); - ShowConsist(trainset); - UpdateTrainStats(trainset); + RedrawConsist(trainset); }); var db = AppServices.Current.Library.Vehicles; @@ -1127,9 +1266,7 @@ public partial class Scenarios : UserControl vehicles.InsertRange(target, block); - RefreshVehicleLabels(); - ShowConsist(trainset); - UpdateTrainStats(trainset); + RedrawConsist(trainset); } private static bool HoldsNext(Dynamic car) => @@ -1158,13 +1295,8 @@ public partial class Scenarios : UserControl return; } - var db = AppServices.Current.Library.Vehicles; StarterNG.Infrastructure.StatsBar.Fill(trainStats, TrainsetDisplay.StatsFields( - trainset, - car => AppServices.Current.Physics.For(car.DataFolder, db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.Model) - ?? AppServices.Current.Physics.For(car.DataFolder, car.MmdFile) - ?? AppServices.Current.Physics.For(car.DataFolder, car.SkinFile), - car => db.TextureFor(car.DataFolder, car.SkinFile, car.MmdFile)?.ResolvedCategory)); + trainset, _info.PhysicsFor, _info.CategoryOf)); } private void RefreshSelectedConsist()