From 2ebff91316c3276679c9c8af5558f4b8f7e7df7b Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 1 Nov 2026 16:06:16 +0100 Subject: [PATCH] Start the scenery editor from its own button and note launches With no scenery selected the button starts a new one in the editor. Each launch notes the scenery, the timetable number and the vehicles for the tapes. Tools work on the selected scenery, and an emptied consist stays listed. --- StarterNG.Tests/SimulatorAftermathTests.cs | 33 ++- StarterNG.Tests/SimulatorExitTests.cs | 19 +- StarterNG.Tests/StartSimulationTests.cs | 269 ++++++++++++++++- .../Abstractions/IProcessLauncher.cs | 11 +- StarterNG/Application/SimulatorAftermath.cs | 16 +- StarterNG/Application/StartSimulation.cs | 117 ++++++-- .../Adapters/SystemProcessLauncher.cs | 21 +- StarterNG/Views/Scenarios.axaml | 32 +- StarterNG/Views/Scenarios.axaml.cs | 276 +++++++++++++----- 9 files changed, 670 insertions(+), 124 deletions(-) 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()