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