diff --git a/StarterNG/Application/AppServices.cs b/StarterNG/Application/AppServices.cs index 201b53b..74fa09c 100644 --- a/StarterNG/Application/AppServices.cs +++ b/StarterNG/Application/AppServices.cs @@ -57,6 +57,7 @@ public sealed class AppServices SettingsStore = new SettingsStore(files, clock, log, settingsPaths, new SettingsSerializer(), Executables); SettingsPaths = settingsPaths; MissingVehicleLog = new MissingVehicleLog(SettingsStore.Settings, MissingAssets, Library, log); + StartSimulation = new StartSimulation(State, SettingsStore, files, Processes, Random, log); } /// @@ -128,6 +129,9 @@ public sealed class AppServices public MissingVehicleLog MissingVehicleLog { get; } + /// Starts the simulator on the current selection. + public StartSimulation StartSimulation { get; } + /// /// Exposed as the concrete service rather than /// because the XAML binds to it as a change-notifying source; code should take diff --git a/StarterNG/Application/StartSimulation.cs b/StarterNG/Application/StartSimulation.cs new file mode 100644 index 0000000..fd28b53 --- /dev/null +++ b/StarterNG/Application/StartSimulation.cs @@ -0,0 +1,188 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using StarterNG.Application.Abstractions; +using StarterNG.Classes; +using StarterNG.Domain; +using StarterNG.Domain.Settings; + +namespace StarterNG.Application; + +/// Why a start attempt ended the way it did. +public enum SimulationStartOutcome +{ + Started, + + /// Nothing is selected to run. + NothingSelected, + + /// The scenario could not be written for the simulator to read. + ExportFailed, + + /// The executable is missing, not executable, or built for another platform. + ExecutableProblem, + + /// The executable is fine but the process would not start. + LaunchFailed +} + +/// +/// What happened, and what the caller needs in order to say so or to watch the +/// simulator it just started. +/// +public readonly record struct SimulationStartResult( + SimulationStartOutcome Outcome, + IProcessHandle? Process = null, + string ExecutablePath = "", + ExeProblem Problem = ExeProblem.None, + string? Detail = null); + +/// +/// Starts the simulator on the scenario and consist the user has selected. +/// +/// +/// This was the middle of a 100-line click handler that also drove dialogs and +/// the loading screen. The sequence itself - settle the consist's starting +/// state, write the scenario the simulator will read, save the settings, launch - +/// is application logic and belongs away from the window; what is left in the +/// view is asking the user things and showing what came back. +/// +public sealed class StartSimulation +{ + /// + /// A scenario the starter has rewritten is saved beside the original under a + /// '$' name, which the simulator loads and the scenery list ignores. + /// + private const char ExportPrefix = '$'; + + /// Battery voltage stood in for by a token velocity, as the format demands. + private const float BatteryOnVelocity = 0.1f; + + private readonly AppState _state; + private readonly SettingsStore _settings; + private readonly IFileSystem _files; + 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) + { + _state = state; + _settings = settings; + _files = files; + _processes = processes; + _random = random; + _log = log; + } + + /// + /// The vehicle the player would start in, or null when the consist has no one + /// aboard - which is what the caller warns about before committing. + /// + public static string? StartableVehicle(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; + + if (!string.IsNullOrEmpty(preferred)) + { + var picked = trainset.Vehicles.FirstOrDefault(vehicle => + string.Equals(vehicle.Name, preferred, StringComparison.OrdinalIgnoreCase)); + if (picked is not null && CanStart(picked)) + return picked.Name; + } + + return trainset.Vehicles.FirstOrDefault(CanStart)?.Name; + } + + /// Start as a free camera rather than in a vehicle. + /// Write the settings before launching. + public SimulationStartResult Execute(bool freeFly, bool saveSettings) + { + var scenery = _state.CurrentScenery; + var trainset = _state.CurrentTrainset; + if (scenery is null || trainset is null) + return new SimulationStartResult(SimulationStartOutcome.NothingSelected); + + ApplyBatteryDefault(trainset); + + string? vehicle = TrainsetDisplay.UniquifyForLaunch(trainset, scenery, _state.StartingVehicleName) + ?? StartableVehicle(trainset, _state.StartingVehicleName); + _state.StartingVehicleName = vehicle; + + // The export always rewrites the weather block, so a run reflects what the + // weather tab shows even when the user never touched it. + scenery.Weather.Dirty = true; + + string exportName = ExportPrefix + Path.GetFileName(scenery.Path); + string exportPath = Path.Combine(Path.GetDirectoryName(scenery.Path) ?? "scenery", exportName); + try + { + _files.WriteAllText(exportPath, + scenery.BuildExportContent(_settings.Settings.IgnoreIrrelevantTrains, _random), + LegacyEncoding()); + } + catch (Exception ex) + { + _log.Log($"writing {exportPath}", ex); + return new SimulationStartResult(SimulationStartOutcome.ExportFailed, Detail: ex.Message, + ExecutablePath: exportPath); + } + + if (saveSettings) + _settings.CaptureAndSave(); + + string executable = Path.GetFullPath(_settings.ResolveExecutable(out var problem)); + if (problem != ExeProblem.None) + return new SimulationStartResult(SimulationStartOutcome.ExecutableProblem, ExecutablePath: executable, + Problem: problem); + + string[] arguments = freeFly || string.IsNullOrEmpty(vehicle) + ? new[] { "-s", exportName } + : new[] { "-s", exportName, "-v", vehicle }; + + var process = _processes.Start(executable, arguments, Path.GetDirectoryName(executable), out string? error); + if (process is null) + return new SimulationStartResult(SimulationStartOutcome.LaunchFailed, ExecutablePath: executable, + Detail: error); + + return new SimulationStartResult(SimulationStartOutcome.Started, process, executable); + } + + /// + /// Applies the user's battery preference to the consist. The scenery format + /// carries the battery state in the trainset's velocity field, so "always on" + /// means a token non-zero value when the consist is standing still. + /// + private void ApplyBatteryDefault(Trainset trainset) + { + if (trainset.Vehicles.Count == 0) + return; + + trainset.Velocity = _settings.Settings.BatteryDefault switch + { + BatteryDefault.AlwaysOff => 0f, + BatteryDefault.AlwaysOn => MathF.Abs(trainset.OriginalVelocity) < 0.01f + ? BatteryOnVelocity + : trainset.OriginalVelocity, + _ => trainset.OriginalVelocity + }; + } + + private static Encoding LegacyEncoding() + { + try + { + return Encoding.GetEncoding(1250); + } + catch (Exception) + { + return Encoding.Latin1; + } + } +} diff --git a/StarterNG/MainWindow.axaml.cs b/StarterNG/MainWindow.axaml.cs index 4d389d9..cd2a72f 100644 --- a/StarterNG/MainWindow.axaml.cs +++ b/StarterNG/MainWindow.axaml.cs @@ -17,6 +17,7 @@ using StarterNG.Infrastructure; using StarterNG.Services; using StarterNG.Views; using StarterNG.Application; +using StarterNG.Application.Abstractions; using StarterNG.Domain.Sceneries; using StarterNG.Domain.Settings; @@ -413,13 +414,15 @@ public partial class MainWindow : Window menu.Open(startButton); } + /// + /// Runs the start use case and turns whatever it reports into something the + /// user sees. Everything that is not a dialog lives in . + /// private async Task LaunchAsync(bool saveSettings, bool freeFly) { var scenery = AppServices.Current.State.CurrentScenery; - if (scenery is null) - return; var trainset = AppServices.Current.State.CurrentTrainset; - if (trainset is null) + if (scenery is null || trainset is null) return; if (!freeFly && !HasStartableVehicle(trainset) && @@ -427,65 +430,28 @@ public partial class MainWindow : Window App.Loc["StartNoStaffTitle"], MessageBoxButtons.YesNo)) return; - if (trainset.Vehicles.Count > 0) - { - trainset.Velocity = AppServices.Current.Settings.BatteryDefault switch - { - BatteryDefault.AlwaysOff => 0f, - BatteryDefault.AlwaysOn => MathF.Abs(trainset.OriginalVelocity) < 0.01f ? 0.1f : trainset.OriginalVelocity, - _ => trainset.OriginalVelocity - }; - } - - string? vehicle = TrainsetDisplay.UniquifyForLaunch( - trainset, scenery, AppServices.Current.State.StartingVehicleName); - if (string.IsNullOrEmpty(vehicle)) - vehicle = ResolveStartVehicle(trainset); - AppServices.Current.State.StartingVehicleName = vehicle; - LoadingScreen.Prepare(trainset.Logo, Path.GetFileNameWithoutExtension(scenery.Path)); - scenery.Weather.Dirty = true; + var result = AppServices.Current.StartSimulation.Execute(freeFly, saveSettings); + switch (result.Outcome) + { + case SimulationStartOutcome.NothingSelected: + return; - string dir = Path.GetDirectoryName(scenery.Path) ?? "scenery"; - string exportName = "$" + Path.GetFileName(scenery.Path); - string exportPath = Path.Combine(dir, exportName); - try - { - File.WriteAllText(exportPath, scenery.BuildExportContent(AppServices.Current.Settings.IgnoreIrrelevantTrains, AppServices.Current.Random), Encoding.GetEncoding(1250)); - } - catch (Exception ex) - { - await Diagnostics.ReportAsync($"{exportPath}", ex, App.Loc["FaultTitle"]); - return; - } + case SimulationStartOutcome.ExportFailed: + await Diagnostics.ReportAsync( + $"{result.ExecutablePath}{Environment.NewLine}{Environment.NewLine}{result.Detail}", + App.Loc["FaultTitle"]); + return; - if (saveSettings) - AppServices.Current.SettingsStore.CaptureAndSave(); + case SimulationStartOutcome.ExecutableProblem: + await ShowExeProblem(result.Problem, result.ExecutablePath); + return; - string exe = Path.GetFullPath(AppServices.Current.SettingsStore.ResolveExecutable(out var exeProblem)); - if (exeProblem != ExeProblem.None) - { - await ShowExeProblem(exeProblem, exe); - return; - } - - Process? sim; - try - { - sim = Process.Start(new ProcessStartInfo - { - FileName = exe, - Arguments = freeFly ? $"-s {exportName}" : $"-s {exportName} -v {vehicle}", - WorkingDirectory = Path.GetDirectoryName(exe) ?? Directory.GetCurrentDirectory(), - UseShellExecute = false - }); - } - catch (Exception ex) - { - await MessageBox.Show(this, $"{exe}\n\n{ex.Message}", - App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok); - return; + case SimulationStartOutcome.LaunchFailed: + await MessageBox.Show(this, $"{result.ExecutablePath}\n\n{result.Detail}", + App.Loc["ExeLaunchFailed"], MessageBoxButtons.Ok); + return; } if (AppServices.Current.Settings.AutoCloseStarter) @@ -494,61 +460,30 @@ public partial class MainWindow : Window return; } + // The starter sits idle while the simulator runs; give the memory back. GC.Collect(); GC.WaitForPendingFinalizers(); - WatchSimulator(sim); + WatchSimulator(result.Process); } - private static string? ResolveStartVehicle(Trainset? trainset) + /// + /// Brings the starter back when the simulator exits. Adopts an already + /// running simulator when we did not start it ourselves. + /// + private void WatchSimulator(IProcessHandle? simulator) { - if (trainset is null || trainset.Vehicles.Count == 0) - return null; + simulator ??= AppServices.Current.Processes.FindRunning( + Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable())); - static bool CanStart(Dynamic v) => - v.DriverType is eDriverType.Headdriver or eDriverType.Reardriver or eDriverType.Passenger; - - string? selected = AppServices.Current.State.StartingVehicleName; - if (!string.IsNullOrEmpty(selected)) + if (simulator is null) { - var pick = trainset.Vehicles.FirstOrDefault(v => - string.Equals(v.Name, selected, StringComparison.OrdinalIgnoreCase)); - if (pick != null && CanStart(pick)) - return pick.Name; - } - - return trainset.Vehicles.FirstOrDefault(CanStart)?.Name - ?? trainset.Vehicles.FirstOrDefault()?.Name; - } - - private void WatchSimulator(Process? sim) - { - if (sim is null) - { - string name = Path.GetFileNameWithoutExtension(AppServices.Current.SettingsStore.ResolveExecutable()); - sim = Process.GetProcessesByName(name).FirstOrDefault(); - } - - if (sim is null) - { - Dispatcher.UIThread.Post(RestoreFromSimulator); return; } - var watcher = new Thread(() => - { - try { sim.WaitForExit(); } - catch - { - } - Dispatcher.UIThread.Post(RestoreFromSimulator); - }) - { - IsBackground = true, - Name = "SimulatorWatcher" - }; - watcher.Start(); + _ = simulator.WaitForExitAsync().ContinueWith( + _ => Dispatcher.UIThread.Post(RestoreFromSimulator), TaskScheduler.Default); } private async void CheckExternalSettings()