Extract starting the simulator into StartSimulation

The click handler is left with asking the user things and showing the result.
This commit is contained in:
maj00r
2026-09-04 21:14:52 +02:00
parent 3c502f630d
commit 964884bd56
3 changed files with 227 additions and 100 deletions

View File

@@ -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);
}
/// <summary>
@@ -128,6 +129,9 @@ public sealed class AppServices
public MissingVehicleLog MissingVehicleLog { get; }
/// <summary>Starts the simulator on the current selection.</summary>
public StartSimulation StartSimulation { get; }
/// <summary>
/// Exposed as the concrete service rather than <see cref="ILocalizedStrings"/>
/// because the XAML binds to it as a change-notifying source; code should take

View File

@@ -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;
/// <summary>Why a start attempt ended the way it did.</summary>
public enum SimulationStartOutcome
{
Started,
/// <summary>Nothing is selected to run.</summary>
NothingSelected,
/// <summary>The scenario could not be written for the simulator to read.</summary>
ExportFailed,
/// <summary>The executable is missing, not executable, or built for another platform.</summary>
ExecutableProblem,
/// <summary>The executable is fine but the process would not start.</summary>
LaunchFailed
}
/// <summary>
/// What happened, and what the caller needs in order to say so or to watch the
/// simulator it just started.
/// </summary>
public readonly record struct SimulationStartResult(
SimulationStartOutcome Outcome,
IProcessHandle? Process = null,
string ExecutablePath = "",
ExeProblem Problem = ExeProblem.None,
string? Detail = null);
/// <summary>
/// Starts the simulator on the scenario and consist the user has selected.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class StartSimulation
{
/// <summary>
/// A scenario the starter has rewritten is saved beside the original under a
/// '$' name, which the simulator loads and the scenery list ignores.
/// </summary>
private const char ExportPrefix = '$';
/// <summary>Battery voltage stood in for by a token velocity, as the format demands.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <param name="freeFly">Start as a free camera rather than in a vehicle.</param>
/// <param name="saveSettings">Write the settings before launching.</param>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
}

View File

@@ -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);
}
/// <summary>
/// Runs the start use case and turns whatever it reports into something the
/// user sees. Everything that is not a dialog lives in <see cref="StartSimulation"/>.
/// </summary>
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)
/// <summary>
/// Brings the starter back when the simulator exits. Adopts an already
/// running simulator when we did not start it ourselves.
/// </summary>
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()