From 3083523cebd914645385e63cc5f1ea8c7a718c36 Mon Sep 17 00:00:00 2001 From: maj00r Date: Sun, 4 Oct 2026 13:27:28 +0200 Subject: [PATCH] Let the simulator be picked from outside the installation A build tree kept beside the data can be chosen in the settings, and when no simulator is found the starter asks for one instead of calling the installation broken. The simulator now runs in the installation, not in the folder of its binary, and a binary for the other system is no longer refused. --- StarterNG.Tests/InstallationCheckTests.cs | 34 ++++++ StarterNG.Tests/StartSimulationTests.cs | 105 ++++++++++++++++++ StarterNG/App.axaml.cs | 6 +- StarterNG/Application/AppServices.cs | 3 +- StarterNG/Application/StartSimulation.cs | 20 +++- StarterNG/Domain/TrainsetDisplay.cs | 16 +-- StarterNG/Infrastructure/Diagnostics.cs | 16 ++- StarterNG/Infrastructure/InstallationCheck.cs | 15 ++- StarterNG/MainWindow.axaml.cs | 30 +++-- StarterNG/Views/ExeProblemText.cs | 14 +++ StarterNG/Views/Settings.axaml | 7 +- StarterNG/Views/Settings.axaml.cs | 58 +++++++++- StarterNG/startercfg/lang/cz.xml | 9 +- StarterNG/startercfg/lang/en.xml | 9 +- StarterNG/startercfg/lang/hu.xml | 9 +- StarterNG/startercfg/lang/pl.xml | 9 +- StarterNG/startercfg/lang/ru.xml | 9 +- 17 files changed, 320 insertions(+), 49 deletions(-) create mode 100644 StarterNG.Tests/StartSimulationTests.cs create mode 100644 StarterNG/Views/ExeProblemText.cs diff --git a/StarterNG.Tests/InstallationCheckTests.cs b/StarterNG.Tests/InstallationCheckTests.cs index ffe1215..dc95d57 100644 --- a/StarterNG.Tests/InstallationCheckTests.cs +++ b/StarterNG.Tests/InstallationCheckTests.cs @@ -40,4 +40,38 @@ public class InstallationCheckTests Assert.DoesNotContain(faults, fault => fault.StartsWith("missing ")); Assert.DoesNotContain("FaultNoWeights", faults); } + + [Fact] + public void A_missing_simulator_is_asked_for_rather_than_called_a_broken_installation() + { + // The simulator may be a build kept elsewhere, or a Windows build for Wine. + var installation = new TestInstallation(); + var check = Check(installation); + + Assert.DoesNotContain("FaultNoExe", check.Run()); + Assert.True(check.ExecutableMissing()); + } + + [Fact] + public void A_simulator_in_the_installation_is_not_missing() + { + var installation = new TestInstallation(new InMemoryFileSystem().WithExecutable(TestInstallation.At("eu07"))); + + Assert.False(Check(installation).ExecutableMissing()); + } + + [Fact] + public void A_simulator_picked_from_elsewhere_is_not_missing() + { + string build = Path.Combine(Path.GetTempPath(), "maszyna", "bin", "eu07_2026-10-04_fc195880"); + var installation = new TestInstallation(new InMemoryFileSystem().WithExecutable(build)); + installation.SettingsStore.Settings.SelectExeAutomatically = false; + installation.SettingsStore.Settings.ExecutablePath = build; + + Assert.False(Check(installation).ExecutableMissing()); + } + + private static InstallationCheck Check(TestInstallation installation) => + new(installation.Paths, installation.Files, installation.Strings, installation.Physics, installation.Library, + installation.SettingsStore); } diff --git a/StarterNG.Tests/StartSimulationTests.cs b/StarterNG.Tests/StartSimulationTests.cs new file mode 100644 index 0000000..0a489f6 --- /dev/null +++ b/StarterNG.Tests/StartSimulationTests.cs @@ -0,0 +1,105 @@ +using StarterNG.Application; +using StarterNG.Application.Abstractions; +using StarterNG.Infrastructure.Sceneries; +using StarterNG.Tests.Fakes; + +namespace StarterNG.Tests; + +public class StartSimulationTests +{ + // A simulator built from source: its own tree, away from the game data. + private static readonly string Build = + Path.Combine(Path.GetTempPath(), "maszyna", "cmake-build-diag", "bin", "eu07_2026-10-04_fc195880"); + + [Fact] + public void A_simulator_from_a_build_tree_runs_in_the_installation() + { + var (start, launcher, installation) = Rig(files => files.WithExecutable(Build)); + + var result = start.Execute(freeFly: true, saveSettings: false); + + Assert.Equal(SimulationStartOutcome.Started, result.Outcome); + Assert.Equal(Path.GetFullPath(Build), launcher.Executable); + Assert.Equal(installation.Paths.Root, launcher.WorkingDirectory); + } + + [Fact] + public void A_build_for_the_other_system_is_still_launched() + { + // A Windows build on Linux: Wine or Proton may well run it. + var (start, launcher, _) = Rig(files => files.WithExecutable(Build, "MZ\u0090\u0000")); + + Assert.Equal(SimulationStartOutcome.Started, start.Execute(freeFly: true, saveSettings: false).Outcome); + Assert.NotNull(launcher.Executable); + } + + [Fact] + public void A_file_that_cannot_be_run_is_not() + { + var (start, launcher, _) = Rig(files => files.WithFile(Build, "\u007fELF")); + + var result = start.Execute(freeFly: true, saveSettings: false); + + Assert.Equal(SimulationStartOutcome.ExecutableProblem, result.Outcome); + Assert.Equal(Domain.Settings.ExeProblem.NotExecutable, result.Problem); + Assert.Null(launcher.Executable); + } + + private static (StartSimulation Start, RecordingLauncher Launcher, TestInstallation Installation) Rig( + Action addSimulator) + { + var installation = new TestInstallation(); + addSimulator(installation.Files); + + var settings = installation.SettingsStore.Settings; + settings.SelectExeAutomatically = false; + settings.ExecutablePath = Build; + + var scenery = new SceneryParser(installation.Clock, installation.Random).Parse( + TestInstallation.At("scenery", "td.scn"), + """ + //$n Test + trainset pociag tor_1 10 0 + node -1 -1 ep07-001 dynamic PKP/EP07 ep07 ep07.mmd 0 headdriver 0 + endtrainset + FirstInit + """); + var state = new AppState { CurrentScenery = scenery, CurrentTrainset = scenery.Trainsets[0] }; + + var launcher = new RecordingLauncher(); + var start = new StartSimulation(state, installation.SettingsStore, installation.Library.Vehicles, + installation.Files, installation.Paths, launcher, installation.Random, + installation.Log); + return (start, launcher, installation); + } + + private sealed class RecordingLauncher : IProcessLauncher + { + public string? Executable { get; private set; } + + public string? WorkingDirectory { get; private set; } + + public IReadOnlyList? Arguments { get; private set; } + + public IProcessHandle? Start(string executablePath, IReadOnlyList arguments, string? workingDirectory, + out string? error) + { + Executable = executablePath; + Arguments = arguments; + WorkingDirectory = workingDirectory; + error = null; + return new RunningProcess(); + } + + public bool OpenInShell(string pathOrUrl) => false; + + public IProcessHandle? FindRunning(string processName) => null; + } + + private sealed class RunningProcess : IProcessHandle + { + public bool HasExited => false; + + public Task WaitForExitAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + } +} diff --git a/StarterNG/App.axaml.cs b/StarterNG/App.axaml.cs index 52a2cad..283d5bf 100644 --- a/StarterNG/App.axaml.cs +++ b/StarterNG/App.axaml.cs @@ -176,13 +176,17 @@ public partial class App : Avalonia.Application $"{Loc["FaultLoadData"]}{Environment.NewLine}{Environment.NewLine}{Loc["FaultDetail"]} {inner.Message}"); } - var faults = Diagnostics.CheckInstallation(); + var check = Diagnostics.InstallationCheck(); + var faults = check.Run(); if (faults.Count > 0) { await Diagnostics.ReportAsync( string.Join(Environment.NewLine, faults) + Environment.NewLine + Environment.NewLine + Loc["FaultBadInstall"]); } + + if (check.ExecutableMissing()) + await main.OfferExecutablePickAsync(); }); }); } diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index e7f4b44..0ac4e72 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -59,7 +59,8 @@ public sealed class AppServices assembly.GetCustomAttribute()?.InformationalVersion); Updates = new UpdateCheck(SettingsStore.Settings, new GitHubReleaseFeed(log, version), version, commit, files, clock, log, settingsPaths.UpdateCheckPath()); - StartSimulation = new StartSimulation(State, SettingsStore, files, Processes, Random, log); + StartSimulation = new StartSimulation(State, SettingsStore, Library.Vehicles, files, paths, Processes, + Random, log); } public static AppServices Current => diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs index 8e05d29..d7c8e2f 100644 --- a/StarterNG/Application/StartSimulation.cs +++ b/StarterNG/Application/StartSimulation.cs @@ -6,6 +6,7 @@ using StarterNG.Application.Abstractions; using StarterNG.Classes; using StarterNG.Domain; using StarterNG.Domain.Settings; +using StarterNG.Domain.Vehicles; using StarterNG.Infrastructure.Adapters; namespace StarterNG.Application; @@ -37,17 +38,21 @@ public sealed class StartSimulation private readonly AppState _state; private readonly SettingsStore _settings; + private readonly VehicleCatalog _vehicles; private readonly IFileSystem _files; + private readonly IGamePaths _paths; private readonly IProcessLauncher _processes; private readonly IRandomSource _random; private readonly IDiagnosticsLog _log; - public StartSimulation(AppState state, SettingsStore settings, IFileSystem files, IProcessLauncher processes, - IRandomSource random, IDiagnosticsLog log) + public StartSimulation(AppState state, SettingsStore settings, VehicleCatalog vehicles, IFileSystem files, + IGamePaths paths, IProcessLauncher processes, IRandomSource random, IDiagnosticsLog log) { _state = state; _settings = settings; + _vehicles = vehicles; _files = files; + _paths = paths; _processes = processes; _random = random; _log = log; @@ -91,7 +96,7 @@ public sealed class StartSimulation break; } - string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName) + string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName, _vehicles) ?? StartableVehicle(trainset, _state.StartingVehicleName); _state.StartingVehicleName = vehicle; @@ -116,7 +121,10 @@ public sealed class StartSimulation _settings.CaptureAndSave(); string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem)); - if (problem != ExeProblem.None) + + // A binary for the other system is let through: on Linux a Windows build + // runs under Wine or Proton, and only the launch can tell. + if (problem is not (ExeProblem.None or ExeProblem.WrongPlatform)) return new SimulationStartResult(SimulationStartOutcome.ExecutableProblem, ExecutablePath: executable, Problem: problem); @@ -124,7 +132,9 @@ public sealed class StartSimulation ? new[] { "-s", exportName } : new[] { "-s", exportName, "-v", vehicle }; - var process = _processes.Start(executable, arguments, Path.GetDirectoryName(executable), out string? error); + // 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 process = _processes.Start(executable, arguments, _paths.Root, out string? error); if (process is null) return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable, Detail: error); diff --git a/StarterNG/Domain/TrainsetDisplay.cs b/StarterNG/Domain/TrainsetDisplay.cs index 10037af..dbc29e6 100644 --- a/StarterNG/Domain/TrainsetDisplay.cs +++ b/StarterNG/Domain/TrainsetDisplay.cs @@ -199,10 +199,10 @@ public static class TrainsetDisplay return p.LoadAccepted.Contains(loadType, StringComparison.OrdinalIgnoreCase); } - public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName) + public static string? UniquifyForLaunch(Trainset trainset, Scenery scenery, string? startName, + VehicleCatalog db) { var used = CollectNames(scenery); - var db = AppServices.Current.Library.Vehicles; bool mu = trainset.Vehicles.Any(v => { @@ -221,18 +221,18 @@ public static class TrainsetDisplay if (mu) { foreach (var v in trainset.Vehicles) - Rename(v, used); + Rename(v, used, db); return target?.Name; } if (target != null) - Rename(target, used); + Rename(target, used, db); return target?.Name; } - public static string PreferredBaseName(Dynamic v) + public static string PreferredBaseName(Dynamic v, VehicleCatalog db) { - var tex = AppServices.Current.Library.Vehicles.TextureForSkin(v.SkinFile); + var tex = db.TextureForSkin(v.SkinFile); if (tex != null) { @@ -294,11 +294,11 @@ public static class TrainsetDisplay return (n, ""); } - private static void Rename(Dynamic v, HashSet used) + private static void Rename(Dynamic v, HashSet used, VehicleCatalog db) { var (_, number) = SplitWagonNumber(v.Name); if (!string.IsNullOrEmpty(v.Name)) used.Remove(v.Name!); - v.Name = EnsureUnique(PreferredBaseName(v), used) + number; + v.Name = EnsureUnique(PreferredBaseName(v, db), used) + number; } } diff --git a/StarterNG/Infrastructure/Diagnostics.cs b/StarterNG/Infrastructure/Diagnostics.cs index 2696797..4430690 100644 --- a/StarterNG/Infrastructure/Diagnostics.cs +++ b/StarterNG/Infrastructure/Diagnostics.cs @@ -53,13 +53,11 @@ public static class Diagnostics Dispatcher.UIThread.Post(() => _ = ReportAsync(message, title)); } - public static List CheckInstallation() => - new InstallationCheck( - AppServices.Current.Paths, - AppServices.Current.Files, - AppServices.Current.Localization, - AppServices.Current.Physics, - AppServices.Current.Library, - AppServices.Current.SettingsStore) - .Run(); + public static InstallationCheck InstallationCheck() => + new(AppServices.Current.Paths, + AppServices.Current.Files, + AppServices.Current.Localization, + AppServices.Current.Physics, + AppServices.Current.Library, + AppServices.Current.SettingsStore); } diff --git a/StarterNG/Infrastructure/InstallationCheck.cs b/StarterNG/Infrastructure/InstallationCheck.cs index 0c0bb0c..42ce84f 100644 --- a/StarterNG/Infrastructure/InstallationCheck.cs +++ b/StarterNG/Infrastructure/InstallationCheck.cs @@ -49,10 +49,17 @@ public sealed class InstallationCheck if (_physics.IndexedCount == 0) faults.Add(_strings["FaultNoPhysics"]); - _settings.ResolveExecutable(out var problem); - if (problem == ExeProblem.NotFound) - faults.Add(_strings["FaultNoExe"]); - return faults; } + + /// + /// Kept out of : a simulator missing from the installation does + /// not make it a broken one - it may be a build kept elsewhere, or a Windows + /// build for Wine, waiting to be picked. + /// + public bool ExecutableMissing() + { + _settings.ResolveExecutable(out var problem); + return problem == ExeProblem.NotFound; + } } diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index 6defa47..8fba8f8 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -427,16 +427,28 @@ public partial class MainWindow : Window return trainset.Vehicles.Any(CanStart); } - private Task ShowExeProblem(ExeProblem problem, string exe) - { - string key = problem switch - { - ExeProblem.NotExecutable => "ExeNotExecutable", - ExeProblem.WrongPlatform => "ExeWrongPlatform", - _ => "ExeNotFound" - }; - return MessageBox.Show(this, $"{exe}\n\n{App.Loc[key]}", + private Task ShowExeProblem(ExeProblem problem, string exe) => + MessageBox.Show(this, $"{exe}\n\n{App.Loc[ExeProblemText.Key(problem)]}", App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok); + + /// + /// Asks for the simulator when the installation holds none, and keeps the pick + /// straight away, so the question is not back on the next start. + /// + public async Task OfferExecutablePickAsync() + { + string paragraph = Environment.NewLine + Environment.NewLine; + string message = string.Format(App.Loc["FaultNoExe"], AppServices.Current.Paths.Root) + paragraph; + if (!AppServices.Current.Environment.IsWindows) + message += App.Loc["FaultNoExeWine"] + paragraph; + message += App.Loc["FaultNoExePick"]; + + if (!await MessageBox.Show(this, message, App.Loc["FaultTitle"], MessageBoxButtons.YesNo)) + return; + + NavSettings.IsChecked = true; + if (await SettingsView.BrowseForExecutableAsync()) + SettingsView.Save(); } private async void StartButton_OnClick(object? sender, RoutedEventArgs e) => diff --git a/StarterNG/Views/ExeProblemText.cs b/StarterNG/Views/ExeProblemText.cs new file mode 100644 index 0000000..58161a1 --- /dev/null +++ b/StarterNG/Views/ExeProblemText.cs @@ -0,0 +1,14 @@ +using StarterNG.Domain.Settings; + +namespace StarterNG.Views; + +public static class ExeProblemText +{ + /// The language key explaining why a simulator binary cannot be used as it is. + public static string Key(ExeProblem problem) => problem switch + { + ExeProblem.NotExecutable => "ExeNotExecutable", + ExeProblem.WrongPlatform => "ExeWrongPlatform", + _ => "ExeNotFound" + }; +} diff --git a/StarterNG/Views/Settings.axaml b/StarterNG/Views/Settings.axaml index 49990e3..2a40755 100644 --- a/StarterNG/Views/Settings.axaml +++ b/StarterNG/Views/Settings.axaml @@ -153,7 +153,12 @@ - + + +